Skip to content
markpaper

README.md

v0.3.0 · 11.6 KB

Download file
# @markpaper/hl-kit

Practical add-on toolkit for [Hyperliquid](https://hyperliquid.xyz) on top of the
[`@nktkas/hyperliquid`](https://github.com/nktkas/hyperliquid) SDK.

The SDK signs and sends requests. `hl-kit` does **not** replace it and does not duplicate it: it turns
practical rules of the Hyperliquid API, each with its reason and check date, into small, tested
functions — the parts that usually get rewritten (and broken) in every project:

- price and size rounding that the exchange accepts, the $10 minimum and when a size may be bumped;
- asset ids for perps, spot and HIP-3 dexes, with a validated, cached meta registry;
- a weight-aware throttle for `/info` and `/exchange`, and retries that never double-send an order;
- full account equity (every perp dex + spot stables) that survives account-mode changes;
- fills history with pagination and TWAP slices, round trips, candles with closure status;
- fees, builder fee units, leverage, liquidation and stop math;
- a long-lived WebSocket client with ack tracking, a silence watchdog and reconnect;
- safe order placement with fail-closed per-order outcomes and reconciliation by cloid.

Most modules are pure functions. Everything that reads from Hyperliquid takes an `InfoRequester` —
a plain function `(body, opts?) => Promise<json>` — so you can use the kit's throttled client, a raw
`fetch`, or an SDK `InfoClient` wrapped in a function, and stub it in tests.

## Install

```sh
npm i @markpaper/hl-kit @nktkas/hyperliquid viem
```

Requires **Node.js 22.12+** (the same floor as `@nktkas/hyperliquid` 0.33). ESM only.
`@nktkas/hyperliquid` and `ws` are optional peer dependencies: read-only use (formatting, registry,
equity, history) needs neither. Node 22+ and browsers have a global `WebSocket`; install `ws` only if you
want its socket options (see `NODE_WS_SOCKET_OPTIONS`) or run in a runtime without a global `WebSocket`.

## Imports

Everything is exported flat from the package root, and each module is also available as a namespace:

```ts
import { formatPrice, createAssetRegistry } from '@markpaper/hl-kit';
import { format, assets, transport, errors, account, history, risk, ws, orders } from '@markpaper/hl-kit';
```

| Namespace | What it covers |
| --- | --- |
| `format` | price/size rounding, min notional, slippage prices, pre-send validation |
| `assets` | asset ids, coin names, meta registry, leverage params, US-equity market hours |
| `transport` | throttled `/info` client, weight limiter, retry, HTTP errors, address budget, cache |
| `errors` | parsing `/exchange` responses, error classification, retry advice |
| `account` | snapshot of all dexes, equity, positions, fees, agents, roles |
| `history` | fills, TWAP slices, round trips, PnL, candles, funding, cache keys |
| `risk` | fee tiers, fill accounting, builder fee, margin, liquidation, stop/TP trigger prices, fee-tier arithmetic |
| `ws` | WebSocket client, limits, subscription keys, self-heal, dedup |
| `orders` | intent → wire order, placement, reconciliation, close, cancel, TWAP, agent check |

### Renamed exports

A few names exist in more than one module. In the flat export they are resolved like this (the
namespaces keep the original names):

| Flat export | Source | Note |
| --- | --- | --- |
| `parseUserFees` | `account.parseUserFees` | returns `UserFees`; pairs with `fetchUserFees` |
| `parseUserFeeRates` | `risk.parseUserFees` | returns `AccountFeeRates` (adds staking discount, spot bps); pairs with `readUserFees` |
| `normalizeAddress` | `account.normalizeAddress` | throws `HlAccountError` |
| `normalizeWsAddress` | `ws.normalizeAddress` | throws `WsSubscriptionError` |
| `PositionSide` | `risk.PositionSide` | `'long' \| 'short'` |
| `FillPositionSide` | `history.PositionSide` | `'LONG' \| 'SHORT'` |
| `isSpotCoin` | `assets.isSpotCoin` | identical to `history.isSpotCoin` |
| `MarketKind`, `Numeric` | `format` | identical types in `assets` / `risk` |
| `CandleInterval` | `history` | identical type in `ws` |

## Quick examples

Addresses below are placeholders (`0xYOUR_ADDRESS`).

### Transport and assets

```ts
import { createInfoClient, createAssetRegistry, createWeightLimiter } from '@markpaper/hl-kit';

// Workload policy is explicit; the exchange ceiling is not a safe application default.
const limiter = createWeightLimiter({
  weightPerMinute: config.weightPerMinute,
  burstCapacity: config.burstCapacity,
  maxConcurrent: config.maxConcurrent,
});
const info = createInfoClient({ network: 'mainnet', limiter });

// One process-wide registry: TTL cache, single-flight, stale-on-error, validated meta.
const registry = createAssetRegistry(info);
const btc = await registry.resolve('BTC');          // { assetId: 0, szDecimals, maxLeverage, ... }
const lookup = await registry.lookup('xyz:TSLA');   // 'listed' | 'unlisted' | 'unknown'
if (lookup.status === 'unknown') {
  // Meta is unavailable right now. Never treat this as a delisting.
}
```

### Balance and equity

```ts
import { fetchAccountSnapshot, computeEquity, normalizePositions } from '@markpaper/hl-kit';

const snapshot = await fetchAccountSnapshot(info, '0xYOUR_ADDRESS'); // every perp dex + spot, one tick
const equity = computeEquity(snapshot);
// total = Σ perp accountValue (all dexes) + free stables; null when the spot leg is unreadable
console.log(equity.total, equity.perpEquity, equity.spotFreeStables, equity.marginRatio, equity.trusted);
for (const p of normalizePositions(snapshot)) console.log(p.coin, p.side, p.size, p.roe);
```

### Rounding and an order

```ts
import { ExchangeClient, HttpTransport } from '@nktkas/hyperliquid';
import { privateKeyToAccount } from 'viem/accounts';
import { formatPrice, sizeFromNotional, validateOrder, placeOrders, reconcileByCloid } from '@markpaper/hl-kit';

const px = formatPrice('97123.456', { szDecimals: btc.szDecimals, mode: 'passive', side: 'buy' });
const sz = sizeFromNotional(25, px, btc.szDecimals);
const check = validateOrder({ px, sz, szDecimals: btc.szDecimals }); // { ok, issues, p, s, notional }

const exchange = new ExchangeClient({ transport: new HttpTransport(), wallet: privateKeyToAccount('0x...') });
const result = await placeOrders(exchange, registry, [
  { coin: 'BTC', side: 'buy', size: '0.001', price: '95000', tif: 'Alo' },
  { coin: 'ETH', side: 'sell', size: '0.01', market: { slippage: 0.01 } }, // IoC at mid - 1%
], { info });

for (const o of result.orders) console.log(o.coin, o.outcome.status);
if (result.needsReconcile) {
  // Some order may be live without confirmation: block new placements on those markets first.
  for (const cloid of result.reconcileCloids) await reconcileByCloid(info, '0xYOUR_ADDRESS', cloid);
}
```

`closePosition`, `marketClose`, `cancelOrders`, `limitExchange` (exchange calls through the same
explicit limiter), `interpretExchangeError` / `recommendRetry` and `getAddressBudget` cover the rest of the
order lifecycle.

### Fills history with TWAP

```ts
import {
  fetchFillsByTime, fetchTwapSliceFills, mergeFillsWithTwap, summarizePnl,
} from '@markpaper/hl-kit';

const since = Date.now() - 7 * 86_400_000;
const fills = await fetchFillsByTime(info, '0xYOUR_ADDRESS', { startTime: since }); // paged, dedup by tid
const twap = await fetchTwapSliceFills(info, '0xYOUR_ADDRESS', { startTime: since });

const all = mergeFillsWithTwap(fills.fills, twap.fills, { from: since });
console.log(all.length);
console.log(summarizePnl(fills.fills).net); // closedPnl - fees (+ funding when passed)
```

### Candles

```ts
import { fetchCandles, fetchServerTimeMs, findCandleGaps } from '@markpaper/hl-kit';

const serverTimeMs = await fetchServerTimeMs(info);
const res = await fetchCandles(info, {
  coin: 'xyz:TSLA',              // HIP-3 coin is sent prefixed, without `dex`
  interval: '1h',
  startTime: serverTimeMs - 30 * 86_400_000,
  serverTimeMs,                  // decides whether the newest bar is closed
});
console.log(res.candles.length, res.historyLimited, findCandleGaps(res.candles, '1h'));
```

HL keeps only about 5000 most recent bars per interval; `historyLimited` tells you the window was cut.

### Fees and liquidation

```ts
import {
  readUserFees, feeRatesForVolume, roundTripFeeUsd, liquidationRoe, stopLiquidationBuffer,
} from '@markpaper/hl-kit';

const fees = await readUserFees(info, '0xYOUR_ADDRESS'); // userAddRate = maker, userCrossRate = taker
console.log(fees.makerBps, fees.takerBps, feeRatesForVolume(6_000_000).takerBps);

roundTripFeeUsd(10_000, fees.takerBps);            // both sides, for backtests
liquidationRoe(10, btc.maxLeverage);               // ROE at which the position is liquidated
stopLiquidationBuffer(-0.25, 10, btc.maxLeverage); // <= 0: the stop never fires before liquidation
```

### WebSocket

```ts
// Optional on Node 22+: the global WebSocket is used when `WebSocket` is omitted.
import WebSocket from 'ws';
import { createWsClient, NODE_WS_SOCKET_OPTIONS, type WebSocketConstructorLike } from '@markpaper/hl-kit';

const client = createWsClient({
  WebSocket: WebSocket as unknown as WebSocketConstructorLike, // not needed on Node 22+ / browsers
  socketOptions: NODE_WS_SOCKET_OPTIONS,
});
client.onError((e) => console.warn(e));
const off = client.subscribe({ type: 'trades', coin: 'BTC' }, (trades) => console.log(trades.length));
// later
off();
client.close();
```

WS gives speed, REST is the truth: reconcile positions, orders and balances through REST periodically.
Spot balances are not delivered over WS.

## Experimental parts

Anything tagged `@experimental` in JSDoc comes from documentation, SDK types or a single observation
and was not verified live. It is implemented only where a wrong guess fails safe; the tag
describes the risk. Check these on testnet before relying on them:

- **TWAP**: `placeTwap`, `cancelTwap`, `fetchTwapSliceFillsByTime`.
- **Orders**: cancel by cloid, `scheduleCancel` / `clearScheduledCancel` (dead man's switch),
  `reconcileByCloid`, close-size estimation for the HIP-3 `positionValue` quirk.
- **Assets**: `hip3AssetId` for dexes other than `xyz`, spot registry (`listSpotAssets`, `spotToken`),
  spot price decimals (`pxDecimals(…, 'spot')`).
- **Errors**: several `classifyExchangeError` kinds use wording that has not been verified against live responses.
- **Transport**: response-size weight surcharge, `500 null` treated as an invalid request,
  `surplusAlreadyInCap`.
- **Account**: spot fee rates, `fetchSubAccounts`, `fetchUserAbstraction`, borrowed stables in equity.
- **History**: `fetchUserFunding` / `fetchFundingHistory` pagination and `fundingPayment`.
- **Risk**: staking/referral discount stacking, `parseFeeScheduleTiers`, `estimateLiquidationPrice`.
- **WebSocket**: connection-rate and message-rate limits, some subscription options and fields.

## Knowledge base

The rules behind every function are documented, with the reason and the check date, in
the Hyperliquid knowledge base `knowledge/hl/` of the markpaper repository (start from
`knowledge/hl/README.md`). The knowledge base is licensed separately under CC BY 4.0.

## Development

```sh
npm run typecheck
npm test          # unit tests, no network
npm run build
npm run smoke     # read-only live check against public mainnet data (no addresses, no keys)
```

## License

Apache License 2.0 — see [LICENSE](LICENSE).

Under Section 4(d) of the license, any redistribution of this software or of a derivative work must
retain the [NOTICE](NOTICE) file and credit **"markpaper — hl-kit"**.

## Disclaimer

This is not financial advice and not a recommendation to trade. The Hyperliquid API, limits, fees and
response shapes change without notice. Verify behaviour against the official documentation and on
**testnet** before trading real funds, start with minimal size, and use the software at your own risk.
The software is provided "as is", without warranties of any kind.
All files