SPB Git

Repository Public

adapter.ts 14.0 KB 221 lines typescript

1// Hyperliquid venue adapter — read side (dossier: docs/v4/venues/hyperliquid.md). Perpetuals of the first perp dex only2// (the validator-operated USDC dex; builder-deployed HIP-3 dexes and spot are out of scope).3// Every answer goes through zod (schemas.ts); every amount stays a decimal string.4import { D } from '../../decimal.ts';5import type { AccountState, Candle, FundingPayment, Position } from '../../models.ts';6import type { Capabilities, VenueAdapter } from '../../venue.ts';7import { createHlHttp, type HlHttp, type HlHttpOptions } from './http.ts';8import {9  hlCandles, hlClearinghouseState, hlFills, hlFrontendOpenOrders, hlFundingHistory, hlHistoricalOrders, hlL2Book, hlMeta, hlMetaAndAssetCtxs, hlTrades, hlUserFunding,10  type HlAssetCtx, type HlMeta,11} from './schemas.ts';12import type { HlNetwork } from './signing.ts';13import { createHlStream, type HlStreamOptions } from './stream.ts';14import { createHlTrading, type HlAsset, type HlTrading, type HlTradingOptions } from './trading.ts';1516import { HL_INTERVAL_MS, HL_MAX_CANDLES, hlAccount, hlBook, hlErr as err, hlFillOf, hlInstrument, hlOrder, hlPublicTrade, hlTicker, parseHl } from './mappers.ts';1718const ID = 'hyperliquid' as const;1920// --- adapter -------------------------------------------------------------------------------------------------------------------21export interface HyperliquidOptions extends Omit<HlHttpOptions, 'budget'> {22  /** how long the asset table (meta) is reused for symbol checks; default 60 s */23  metaTtlMs?: number;24  /** stream settings (WebSocket constructor, heartbeat, backoff) */25  stream?: Omit<HlStreamOptions, 'network' | 'now'>;26  /** trading settings; the adapter never holds a key */27  trading?: Pick<HlTradingOptions, 'allowMainnetExchange' | 'vaultAddress' | 'expiresAfterMs'>;28  /** a known asset table (e.g. pinned by the signer): `adapter.trading` is then available synchronously */29  assets?: HlAsset[];30}3132export interface HyperliquidAdapter extends VenueAdapter {33  readonly id: 'hyperliquid';34  readonly http: HlHttp;35  /** asset table from `meta`: symbol → asset index (the integer used in actions) and szDecimals */36  assets(): Promise<HlAsset[]>;37  /** trading object bound to the asset table read from the venue (buildOrder / describe need the asset indices) */38  loadTrading(): Promise<HlTrading>;39  stream: NonNullable<VenueAdapter['stream']>;40  /** closes the websocket and stops every timer */41  close(): void;42}4344export function createHyperliquidAdapter(o: HyperliquidOptions): HyperliquidAdapter {45  const network: HlNetwork = o.network, now = o.now ?? Date.now, http = createHlHttp(o), metaTtlMs = o.metaTtlMs ?? 60_000;46  let cached: { at: number; assets: HlAsset[] } | null = null;47  const remember = (meta: HlMeta): HlAsset[] => {48    const assets = meta.universe.map((u, i) => ({ symbol: u.name, asset: i, szDecimals: u.szDecimals, maxLeverage: u.maxLeverage, isolatedOnly: hlInstrument(u).marginModes.length === 1, delisted: u.isDelisted === true }));49    cached = { at: now(), assets };50    return assets;51  };52  const assets = async (): Promise<HlAsset[]> => {53    if (cached && now() - cached.at < metaTtlMs) return cached.assets;54    return remember(parseHl(hlMeta, await http.info({ type: 'meta' }), 'meta'));55  };56  const known = async (symbol: string): Promise<HlAsset> => {57    const a = (await assets()).find((x) => x.symbol === symbol);58    if (!a) throw err('unsupported', `unknown instrument '${String(symbol).slice(0, 40)}'`);59    return a;60  };61  const ctxs = async (): Promise<{ meta: HlMeta; ctxs: HlAssetCtx[]; ts: number }> => {62    const [meta, c] = parseHl(hlMetaAndAssetCtxs, await http.info({ type: 'metaAndAssetCtxs' }), 'metaAndAssetCtxs');63    if (c.length !== meta.universe.length) throw err('bad_answer', 'metaAndAssetCtxs: universe and contexts differ in length');64    remember(meta);65    return { meta, ctxs: c, ts: now() }; // the venue gives no timestamp for this answer: time of receipt66  };67  const stream = createHlStream({ ...o.stream, network, now });6869  const capabilities: Capabilities = {70    marketData: true, account: true, orders: true,71    orderTypes: ['limit', 'market', 'stop_market', 'stop_limit', 'take_profit_market', 'take_profit_limit'], tif: ['gtc', 'ioc', 'alo'],72    modify: false, leverage: true, marginModes: ['cross', 'isolated'], subaccounts: false, testnet: true, stream: true,73    notes: [74      'No fixed tick: prices have at most 5 significant figures and at most 6 − szDecimals decimals; Instrument.tickSize is the finest step.',75      'Market orders are sent as immediate-or-cancel limit orders at the worst acceptable price (price is required).',76      'Stop and take-profit orders need both triggerPrice and price (the limit after triggering; for the *_market types it is the slippage bound).',77      'Trading goes through an API wallet approved by the account; this adapter builds and verifies actions and never holds a key.',78      'First perp dex only: no spot, no builder-deployed (HIP-3) dexes, no sub-accounts or vaults (vaultAddress is not used).',79      'Order modification (modify / batchModify) is not implemented: cancel and place again.',80      'Ticker.volume24h is USD notional; Ticker.ts is the time of receipt (the venue gives none); funding is the hourly rate.',81    ],82  };8384  return {85    id: ID, network, capabilities, http, assets, trading: o.assets ? createHlTrading({ network, assets: o.assets, http, now, ...o.trading }) : undefined, close: () => stream.close(),86    stream(sub, on, onStatus) {87      if (sub.type === 'account') return stream.subscribe(sub, on, onStatus);88      // The server DROPS the connection on a subscription to an unknown coin (close 1006, no message — observed 2026-11-10),89      // which would take every other subscription down with it: the symbol is checked against the venue's table first.90      let cancelled = false, unsubscribe: (() => void) | null = null;91      assets().then((table) => {92        if (cancelled) return;93        if (!table.some((a) => a.symbol === sub.symbol)) { on({ type: 'invalid', channel: 'subscribe', reason: `unknown instrument '${String(sub.symbol).slice(0, 40)}'` }); return; }94        unsubscribe = stream.subscribe(sub, on, onStatus);95      }, (e: unknown) => { if (!cancelled) on({ type: 'invalid', channel: 'subscribe', reason: `asset table unavailable (${e instanceof Error ? e.message : 'error'})`.slice(0, 160) }); });96      return () => { cancelled = true; unsubscribe?.(); };97    },98    async loadTrading() { return createHlTrading({ network, assets: await assets(), http, now, ...o.trading }); },99100    async instruments() { return parseHl(hlMeta, await http.info({ type: 'meta' }), 'meta').universe.map(hlInstrument); },101102    async tickers() {103      const { meta, ctxs: c, ts } = await ctxs();104      return meta.universe.map((u, i) => hlTicker(u.name, c[i]!, ts));105    },106107    async book(symbol, depth) {108      if (depth !== undefined && (!Number.isInteger(depth) || depth < 1)) throw err('unsupported', 'depth must be a positive integer');109      await known(symbol);110      const b = parseHl(hlL2Book, await http.info({ type: 'l2Book', coin: symbol }), 'l2Book');111      if (b.coin !== symbol) throw err('bad_answer', 'l2Book: answer is for another coin');112      return hlBook(b, depth);113    },114115    async trades(symbol, limit) {116      if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw err('unsupported', 'limit must be a positive integer');117      await known(symbol);118      const t = parseHl(hlTrades, await http.info({ type: 'recentTrades', coin: symbol }), 'recentTrades');119      if (t.some((x) => x.coin !== symbol)) throw err('bad_answer', 'recentTrades: answer is for another coin');120      const out = t.map(hlPublicTrade).sort((a, b) => b.ts - a.ts); // newest first121      return limit === undefined ? out : out.slice(0, limit);122    },123124    async candles(symbol, interval, q) {125      const step = HL_INTERVAL_MS[interval];126      if (!step) throw err('unsupported', `interval '${String(interval)}' is not supported`);127      const limit = q.limit ?? 500;128      if (!Number.isInteger(limit) || limit < 1 || limit > HL_MAX_CANDLES) throw err('unsupported', `limit must be between 1 and ${HL_MAX_CANDLES}`);129      await known(symbol);130      const end = q.end ?? now(), start = q.start ?? end - limit * step;131      if (!(start < end)) throw err('unsupported', 'start must be before end');132      const c = parseHl(hlCandles, await http.info({ type: 'candleSnapshot', req: { coin: symbol, interval, startTime: start, endTime: end } }), 'candleSnapshot');133      if (c.some((x) => x.s !== symbol || x.i !== interval)) throw err('bad_answer', 'candleSnapshot: answer is for another coin or interval');134      const out: Candle[] = c.map((x) => ({ ts: x.t, open: x.o, high: x.h, low: x.l, close: x.c, volume: x.v })).sort((a, b) => a.ts - b.ts);135      for (const x of out) if (D.cmp(D.from(x.low), D.from(x.high)) > 0) throw err('bad_answer', 'candleSnapshot: low above high');136      return q.start !== undefined ? out.slice(0, limit) : out.slice(-limit);137    },138139    async fundingHistory(symbol, q) {140      await known(symbol);141      const body: Record<string, unknown> = { coin: symbol, startTime: q.start };142      if (q.end !== undefined) body.endTime = q.end;143      const f = parseHl(hlFundingHistory, await http.info({ type: 'fundingHistory', ...body }), 'fundingHistory');144      if (f.some((x) => x.coin !== symbol)) throw err('bad_answer', 'fundingHistory: answer is for another coin');145      return f.map((x) => ({ ts: x.time, rate: x.fundingRate, premium: x.premium })).sort((a, b) => a.ts - b.ts);146    },147148    async account(account) {149      const user = hlAccount(account);150      const s = parseHl(hlClearinghouseState, await http.info({ type: 'clearinghouseState', user }), 'clearinghouseState');151      const equity = D.from(s.marginSummary.accountValue), used = D.from(s.marginSummary.totalMarginUsed);152      const upnl = s.assetPositions.reduce((n, p) => D.add(n, D.from(p.position.unrealizedPnl)), D.from('0'));153      const free = D.sub(equity, used);154      const state: AccountState = {155        venue: ID, account: user, equity: D.str(equity),156        collateral: D.str(D.sub(equity, upnl)),                 // account value without unrealized PnL (derived exactly from venue fields)157        freeCollateral: free.m < 0n ? '0' : D.str(free),        // account value − margin used (derived exactly from venue fields)158        marginUsed: D.str(used), maintenanceMargin: s.crossMaintenanceMarginUsed, // cross positions only (the venue gives no isolated total)159        withdrawable: s.withdrawable, ts: s.time, source: 'venue',160      };161      return state;162    },163164    async positions(account) {165      const user = hlAccount(account);166      const s = parseHl(hlClearinghouseState, await http.info({ type: 'clearinghouseState', user }), 'clearinghouseState');167      const open = s.assetPositions.map((p) => p.position).filter((p) => !D.isZero(D.from(p.szi)));168      if (open.length === 0) return [];169      const { meta, ctxs: c } = await ctxs(); // the account state carries no mark price: read the venue's own170      return open.map((p): Position => {171        const i = meta.universe.findIndex((u) => u.name === p.coin);172        if (i < 0) throw err('bad_answer', `position on unknown instrument '${p.coin}'`);173        if (p.entryPx === null) throw err('bad_answer', `position ${p.coin}: no entry price`);174        const szi = D.from(p.szi);175        return {176          instrument: `${ID}:${p.coin}`, side: szi.m > 0n ? 'long' : 'short', size: D.str(D.abs(szi)), entryPrice: p.entryPx, markPrice: c[i]!.markPx,177          liquidationPrice: p.liquidationPx, leverage: String(p.leverage.value), marginMode: p.leverage.type, margin: p.marginUsed,178          unrealizedPnl: p.unrealizedPnl, realizedPnl: null, fundingPaid: p.cumFunding.sinceOpen, source: 'venue',179        };180      });181    },182183    async openOrders(account) {184      const user = hlAccount(account);185      const o2 = parseHl(hlFrontendOpenOrders, await http.info({ type: 'frontendOpenOrders', user }), 'frontendOpenOrders');186      return o2.map((x) => hlOrder(x, 'open', x.timestamp));187    },188189    async orderHistory(account, q) {190      const user = hlAccount(account), limit = q?.limit;191      if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw err('unsupported', 'limit must be a positive integer');192      const h = parseHl(hlHistoricalOrders, await http.info({ type: 'historicalOrders', user }), 'historicalOrders');193      // one entry per status change; keep the latest status of each order (same timestamp: the more final one)194      const rank = (st: string): number => (st === 'open' ? 0 : st === 'triggered' ? 1 : 2);195      const latest = new Map<number, (typeof h)[number]>();196      for (const x of h) {197        const p = latest.get(x.order.oid);198        if (!p || x.statusTimestamp > p.statusTimestamp || (x.statusTimestamp === p.statusTimestamp && rank(x.status) > rank(p.status))) latest.set(x.order.oid, x);199      }200      const out = [...latest.values()].map((x) => hlOrder(x.order, x.status, x.statusTimestamp)).sort((a, b) => b.updatedAt - a.updatedAt);201      return limit === undefined ? out : out.slice(0, limit);202    },203204    async fills(account, q) {205      const user = hlAccount(account), limit = q?.limit;206      if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw err('unsupported', 'limit must be a positive integer');207      const body = q?.start !== undefined ? { type: 'userFillsByTime', user, startTime: q.start } : { type: 'userFills', user };208      const f = parseHl(hlFills, await http.info(body), body.type);209      const out = f.map(hlFillOf).sort((a, b) => b.ts - a.ts);210      return limit === undefined ? out : out.slice(0, limit);211    },212213    async funding(account, q) {214      const user = hlAccount(account);215      const f = parseHl(hlUserFunding, await http.info({ type: 'userFunding', user, startTime: q?.start ?? now() - 7 * 86_400_000 }), 'userFunding');216      // amount: USDC credited (+) or debited (−) to the account; size: the signed position (negative = short) at the payment217      return f.map((x): FundingPayment => ({ instrument: `${ID}:${x.delta.coin}`, amount: x.delta.usdc, rate: x.delta.fundingRate, size: x.delta.szi, ts: x.time })).sort((a, b) => a.ts - b.ts);218    },219  };220}221
Files main
Menu

Theme