Repository Public
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