SPB Git

Repository Public

adapter.test.ts 25.5 KB 358 lines typescript

1// Hyperliquid read side against real answers recorded from the public info endpoint of mainnet and testnet2// (fixtures/hyperliquid/record.mts, 2026-11-10/11). No network.3import { describe, expect, it } from 'vitest';4import { D, VenueError, AccountStateSchema, BookSchema, CandleSchema, FillSchema, FundingPaymentSchema, InstrumentSchema, OrderSchema, PositionSchema, PublicTradeSchema, TickerSchema } from '../../src/index.ts';5import { HlWeightBudget, createHyperliquidAdapter, hlExchangeWeight, hlInfoExtraWeight, hlInfoWeight, hlOrderStatus } from '../../src/venues/hyperliquid/index.ts';6import { fx, replay, type Route } from './helpers.ts';78const NETWORKS = ['mainnet', 'testnet'] as const;9const routesOf = (network: 'mainnet' | 'testnet', extra: Record<string, Route> = {}): Record<string, Route> => {10  const f = (n: string) => { const x = fx(`${network}-${n}`); return { status: x.status, body: x.body }; };11  return {12    meta: f('meta'), metaAndAssetCtxs: f('metaAndAssetCtxs'), l2Book: f('l2Book-BTC'), recentTrades: f('recentTrades-BTC'), candleSnapshot: f('candleSnapshot-BTC-1h'),13    fundingHistory: f('fundingHistory-BTC'), clearinghouseState: f('clearinghouseState'), frontendOpenOrders: f('frontendOpenOrders'), historicalOrders: f('historicalOrders'),14    userFills: f('userFills'), userFillsByTime: f('userFillsByTime'), userFunding: f('userFunding'), ...extra,15  };16};17const make = (network: 'mainnet' | 'testnet', extra: Record<string, Route> = {}, now = Date.parse(fx(`${network}-meta`).recordedAt)) => {18  const r = replay(routesOf(network, extra));19  let t = now;20  const adapter = createHyperliquidAdapter({ network, fetch: r.fetchImpl, now: () => t });21  return { adapter, calls: r.calls, advance: (ms: number) => { t += ms; } };22};23const canon = (s: string) => D.str(D.from(s));24const code = async (p: Promise<unknown>): Promise<string> => { try { await p; return 'no error'; } catch (e) { return e instanceof VenueError ? e.code : `not a VenueError: ${e}`; } };2526describe.each(NETWORKS)('hyperliquid read side — %s fixtures', (network) => {27  const account = (fx(`${network}-clearinghouseState`).request as { user: string }).user;2829  it('instruments: lot from szDecimals, finest tick, leverage, isolated-only and delisted flags', async () => {30    const { adapter, calls } = make(network);31    const list = await adapter.instruments(), rawU = fx(`${network}-meta`).body.universe as any[];32    expect(calls[0]!.url).toBe(`https://api.hyperliquid${network === 'testnet' ? '-testnet' : ''}.xyz/info`);33    expect(calls[0]!.body).toEqual({ type: 'meta' });34    expect(list.length).toBe(rawU.length);35    for (const i of list) InstrumentSchema.parse(i);36    const btc = list.find((i) => i.symbol === 'BTC')!, rawBtc = rawU.find((u) => u.name === 'BTC');37    expect(rawBtc.szDecimals).toBe(5);38    expect(btc).toMatchObject({ id: 'hyperliquid:BTC', venue: 'hyperliquid', base: 'BTC', quote: 'USD', kind: 'perpetual', collateral: 'USDC', lotSize: '0.00001', tickSize: '0.1', minNotional: '10', minOrderSize: null,39      maxLeverage: String(rawBtc.maxLeverage), marginModes: ['cross', 'isolated'], fundingIntervalSec: 3600, status: 'active' });40    for (const u of rawU) {41      const i = list.find((x) => x.symbol === u.name)!;42      expect(i.status).toBe(u.isDelisted ? 'halted' : 'active');43      expect(i.marginModes).toEqual(u.onlyIsolated || u.marginMode === 'strictIsolated' || u.marginMode === 'noCross' ? ['isolated'] : ['cross', 'isolated']);44      expect(D.str(D.mul(D.from(i.lotSize), D.from(`1${'0'.repeat(u.szDecimals)}`)))).toBe('1');45      expect(D.str(D.mul(D.from(i.tickSize), D.from(`1${'0'.repeat(6 - u.szDecimals)}`)))).toBe('1');46    }47    expect(list.some((i) => i.status === 'halted')).toBe(true);48    expect(list.some((i) => i.marginModes.length === 1)).toBe(true);49  });5051  it('tickers: mark, oracle as index, mid, hourly funding, open interest, 24 h notional volume and change', async () => {52    const { adapter } = make(network);53    const t = await adapter.tickers(), [meta, ctxs] = fx(`${network}-metaAndAssetCtxs`).body as [any, any[]];54    expect(t.length).toBe(meta.universe.length);55    for (const x of t) TickerSchema.parse(x);56    const i = meta.universe.findIndex((u: any) => u.name === 'BTC'), btc = t[i]!, c = ctxs[i];57    expect(btc).toMatchObject({ instrument: 'hyperliquid:BTC', mark: canon(c.markPx), index: canon(c.oraclePx), mid: canon(c.midPx), funding: canon(c.funding), openInterest: canon(c.openInterest), volume24h: canon(c.dayNtlVlm) });58    // change = (mark − prevDayPx) / prevDayPx rounded to 6 decimals: |change × prev − (mark − prev)| ≤ prev × 0.000000559    const mark = D.from(canon(c.markPx)), prev = D.from(canon(c.prevDayPx));60    const residual = D.abs(D.sub(D.mul(D.from(btc.change24h!), prev), D.sub(mark, prev)));61    expect(D.cmp(residual, D.mul(prev, D.from('0.0000005')))).toBeLessThanOrEqual(0);62    expect(D.from(btc.change24h!).s).toBeLessThanOrEqual(6);63    // an asset without a book has no mid and no premium: null, not zero64    const dead = ctxs.findIndex((x: any) => x.midPx === null);65    if (dead >= 0) expect(t[dead]!.mid).toBeNull();66    expect(typeof JSON.stringify(t)).toBe('string');67    for (const x of t) for (const k of ['mark', 'index', 'mid', 'funding', 'openInterest', 'volume24h', 'change24h'] as const) expect(x[k] === null || typeof x[k] === 'string').toBe(true);68  });6970  it('book: bids descending, asks ascending, depth cut, venue timestamp', async () => {71    const { adapter, calls } = make(network);72    const b = await adapter.book('BTC'), rawB = fx(`${network}-l2Book-BTC`).body;73    BookSchema.parse(b);74    expect(calls.at(-1)!.body).toEqual({ type: 'l2Book', coin: 'BTC' });75    expect(b.ts).toBe(rawB.time);76    expect(b.bids.length).toBe(rawB.levels[0].length);77    expect(b.bids[0]).toEqual([canon(rawB.levels[0][0].px), canon(rawB.levels[0][0].sz)]);78    expect(b.asks[0]).toEqual([canon(rawB.levels[1][0].px), canon(rawB.levels[1][0].sz)]);79    expect(D.cmp(D.from(b.bids[0]![0]), D.from(b.asks[0]![0]))).toBe(-1);80    const top = await adapter.book('BTC', 3);81    expect([top.bids.length, top.asks.length]).toEqual([3, 3]);82  });8384  it('trades: side of the aggressor, unique id from (time, tid), newest first', async () => {85    const { adapter } = make(network);86    const t = await adapter.trades('BTC'), rawT = fx(`${network}-recentTrades-BTC`).body as any[];87    expect(t.length).toBe(rawT.length);88    for (const x of t) PublicTradeSchema.parse(x);89    const r0 = rawT[0], mine = t.find((x) => x.id === `${r0.time}-${r0.tid}`)!;90    expect(mine).toMatchObject({ instrument: 'hyperliquid:BTC', price: canon(r0.px), size: canon(r0.sz), side: r0.side === 'B' ? 'buy' : 'sell', ts: r0.time });91    for (let i = 1; i < t.length; i++) expect(t[i - 1]!.ts).toBeGreaterThanOrEqual(t[i]!.ts);92    expect((await adapter.trades('BTC', 2)).length).toBe(2);93  });9495  it('candles: request shape, ascending, limit', async () => {96    const { adapter, calls } = make(network);97    const end = Date.parse(fx(`${network}-candleSnapshot-BTC-1h`).recordedAt);98    const c = await adapter.candles('BTC', '1h', { end, limit: 10 }), rawC = fx(`${network}-candleSnapshot-BTC-1h`).body as any[];99    expect(calls.at(-1)!.body).toEqual({ type: 'candleSnapshot', req: { coin: 'BTC', interval: '1h', startTime: end - 10 * 3_600_000, endTime: end } });100    expect(c.length).toBe(10);101    for (const x of c) CandleSchema.parse(x);102    const last = rawC.at(-1);103    expect(c.at(-1)).toEqual({ ts: last.t, open: canon(last.o), high: canon(last.h), low: canon(last.l), close: canon(last.c), volume: canon(last.v) });104    for (let i = 1; i < c.length; i++) expect(c[i]!.ts - c[i - 1]!.ts).toBe(3_600_000);105    expect(await code(adapter.candles('BTC', '2h' as never, {}))).toBe('unsupported');106    expect(await code(adapter.candles('BTC', '1h', { limit: 5001 }))).toBe('unsupported');107  });108109  it('funding history: hourly rate and premium', async () => {110    const { adapter, calls } = make(network);111    const f = await adapter.fundingHistory('BTC', { start: 1 }), rawF = fx(`${network}-fundingHistory-BTC`).body as any[];112    expect(calls.at(-1)!.body).toEqual({ type: 'fundingHistory', coin: 'BTC', startTime: 1 });113    expect(f.length).toBe(rawF.length);114    expect(f[0]).toEqual({ ts: rawF[0].time, rate: canon(rawF[0].fundingRate), premium: canon(rawF[0].premium) });115    await adapter.fundingHistory('BTC', { start: 1, end: 2 });116    expect(calls.at(-1)!.body).toEqual({ type: 'fundingHistory', coin: 'BTC', startTime: 1, endTime: 2 });117  });118119  it('account state: account value, margin used, withdrawable, cross maintenance margin', async () => {120    const { adapter, calls } = make(network);121    const a = await adapter.account(account.toUpperCase().replace('0X', '0x')), s = fx(`${network}-clearinghouseState`).body;122    AccountStateSchema.parse(a);123    expect(calls.at(-1)!.body).toEqual({ type: 'clearinghouseState', user: account.toLowerCase() });124    expect(a).toMatchObject({ venue: 'hyperliquid', account: account.toLowerCase(), equity: canon(s.marginSummary.accountValue), marginUsed: canon(s.marginSummary.totalMarginUsed),125      withdrawable: canon(s.withdrawable), maintenanceMargin: canon(s.crossMaintenanceMarginUsed), ts: s.time, source: 'venue' });126    const upnl = (s.assetPositions as any[]).reduce((n, p) => D.add(n, D.from(canon(p.position.unrealizedPnl))), D.from('0'));127    expect(a.collateral).toBe(D.str(D.sub(D.from(a.equity), upnl)));128    expect(D.str(D.add(D.from(a.freeCollateral), D.from(a.marginUsed)))).toBe(a.equity);129  });130131  it('positions: side, size, entry, venue mark, liquidation price, leverage, margin mode, PnL, funding', async () => {132    const { adapter } = make(network);133    const p = await adapter.positions(account), s = fx(`${network}-clearinghouseState`).body, [meta, ctxs] = fx(`${network}-metaAndAssetCtxs`).body as [any, any[]];134    expect(p.length).toBe(s.assetPositions.length);135    expect(p.length).toBeGreaterThan(0);136    for (const x of p) PositionSchema.parse(x);137    for (const { position: r } of s.assetPositions as any[]) {138      const x = p.find((y) => y.instrument === `hyperliquid:${r.coin}`)!, szi = D.from(canon(r.szi));139      expect(x).toMatchObject({ side: szi.m > 0n ? 'long' : 'short', size: D.str(D.abs(szi)), entryPrice: canon(r.entryPx), liquidationPrice: r.liquidationPx === null ? null : canon(r.liquidationPx),140        leverage: String(r.leverage.value), marginMode: r.leverage.type, margin: canon(r.marginUsed), unrealizedPnl: canon(r.unrealizedPnl), realizedPnl: null, fundingPaid: canon(r.cumFunding.sinceOpen), source: 'venue' });141      expect(x.markPrice).toBe(canon(ctxs[meta.universe.findIndex((u: any) => u.name === r.coin)].markPx));142    }143  });144145  it('an account without positions: empty list, no second request', async () => {146    const empty = fx(`${network}-clearinghouseState-empty`);147    const { adapter, calls } = make(network, { clearinghouseState: { body: empty.body } });148    expect(await adapter.positions(account)).toEqual([]);149    expect(calls.map((c) => c.body.type)).toEqual(['clearinghouseState']);150    const a = await adapter.account(account);151    expect(a.marginUsed).toBe('0');152    expect(a.equity).toBe(a.collateral);153  });154155  it('open orders: trigger orders, reduce-only, time in force, cloid', async () => {156    const { adapter, calls } = make(network);157    const o = await adapter.openOrders(account), rawO = fx(`${network}-frontendOpenOrders`).body as any[];158    expect(calls.at(-1)!.body).toEqual({ type: 'frontendOpenOrders', user: account.toLowerCase() });159    expect(o.length).toBe(rawO.length);160    expect(o.length).toBeGreaterThan(0);161    for (const x of o) OrderSchema.parse(x);162    const types: Record<string, string> = { Limit: 'limit', 'Stop Market': 'stop_market', 'Stop Limit': 'stop_limit', 'Take Profit Market': 'take_profit_market', 'Take Profit Limit': 'take_profit_limit', Market: 'market' };163    for (const r of rawO) {164      const x = o.find((y) => y.venueOrderId === String(r.oid))!;165      expect(x).toMatchObject({ instrument: `hyperliquid:${r.coin}`, side: r.side === 'B' ? 'buy' : 'sell', type: types[r.orderType], size: canon(r.origSz), price: canon(r.limitPx), reduceOnly: r.reduceOnly,166        triggerPrice: r.isTrigger ? canon(r.triggerPx) : null, tif: r.tif === null ? 'gtc' : r.tif.toLowerCase(), clientId: r.cloid ?? `oid:${r.oid}`, createdAt: r.timestamp, rejectReason: null });167      expect(x.filled).toBe(D.str(D.sub(D.from(canon(r.origSz)), D.from(canon(r.sz)))));168      expect(x.status).toBe(x.filled === '0' ? 'open' : 'partially_filled');169    }170    expect(o.some((x) => x.triggerPrice !== null && x.reduceOnly)).toBe(true);171  });172173  it('order history: one entry per order with its latest status', async () => {174    const { adapter } = make(network);175    const h = await adapter.orderHistory(account), rawH = fx(`${network}-historicalOrders`).body as any[];176    for (const x of h) OrderSchema.parse(x);177    expect(new Set(h.map((x) => x.venueOrderId)).size).toBe(h.length);178    expect(h.length).toBe(new Set(rawH.map((x) => x.order.oid)).size);179    const statuses = new Set(h.map((x) => x.status));180    expect(statuses.has('filled') || statuses.has('cancelled')).toBe(true);181    const cancelledByVenue = rawH.find((x) => /Canceled$/.test(x.status));182    if (cancelledByVenue) expect(h.find((x) => x.venueOrderId === String(cancelledByVenue.order.oid))).toMatchObject({ status: 'cancelled', rejectReason: cancelledByVenue.status });183    const filled = rawH.find((x) => x.status === 'filled');184    if (filled) expect(h.find((x) => x.venueOrderId === String(filled.order.oid))).toMatchObject({ status: 'filled', filled: canon(filled.order.origSz), size: canon(filled.order.origSz) });185    for (let i = 1; i < h.length; i++) expect(h[i - 1]!.updatedAt).toBeGreaterThanOrEqual(h[i]!.updatedAt);186    expect((await adapter.orderHistory(account, { limit: 3 })).length).toBe(3);187  });188189  it('fills: userFills, and userFillsByTime when a start is given', async () => {190    const { adapter, calls } = make(network);191    const f = await adapter.fills(account), rawF = fx(`${network}-userFills`).body as any[];192    expect(calls.at(-1)!.body).toEqual({ type: 'userFills', user: account.toLowerCase() });193    expect(f.length).toBe(rawF.length);194    for (const x of f) FillSchema.parse(x);195    expect(new Set(f.map((x) => x.id)).size).toBe(f.length);196    const r = rawF[0], x = f.find((y) => y.id === `${r.hash}-${r.tid}`)!;197    expect(x).toMatchObject({ instrument: `hyperliquid:${r.coin}`, orderId: String(r.oid), clientId: r.cloid ?? null, side: r.side === 'B' ? 'buy' : 'sell', price: canon(r.px), size: canon(r.sz), fee: canon(r.fee),198      feeAsset: r.feeToken, closedPnl: canon(r.closedPnl), ts: r.time, liquidation: false });199    const g = await adapter.fills(account, { start: 123, limit: 5 });200    expect(calls.at(-1)!.body).toEqual({ type: 'userFillsByTime', user: account.toLowerCase(), startTime: 123 });201    expect(g.length).toBe(5);202  });203204  it('a liquidation fill is flagged', async () => {205    const body = structuredClone(fx(`${network}-userFills`).body);206    body[0].liquidation = { liquidatedUser: account, markPx: '1.0', method: 'market' };207    const { adapter } = make(network, { userFills: { body } });208    const f = await adapter.fills(account);209    expect(f.filter((x) => x.liquidation).length).toBe(1);210  });211212  it('funding payments: signed amount, rate, signed position', async () => {213    const { adapter, calls, advance } = make(network);214    const f = await adapter.funding(account, { start: 5 }), rawF = fx(`${network}-userFunding`).body as any[];215    expect(calls.at(-1)!.body).toEqual({ type: 'userFunding', user: account.toLowerCase(), startTime: 5 });216    expect(f.length).toBe(rawF.length);217    for (const x of f) FundingPaymentSchema.parse(x);218    const r = rawF[0];219    expect(f.find((x) => x.ts === r.time && x.instrument === `hyperliquid:${r.delta.coin}`)).toEqual({ instrument: `hyperliquid:${r.delta.coin}`, amount: canon(r.delta.usdc), rate: canon(r.delta.fundingRate), size: canon(r.delta.szi), ts: r.time });220    advance(0);221    await adapter.funding(account);222    expect(calls.at(-1)!.body.startTime).toBe(Date.parse(fx(`${network}-meta`).recordedAt) - 7 * 86_400_000);223  });224225  it('recorded error answers: unknown coin is HTTP 500 "null", bad type and bad user are HTTP 422', async () => {226    const unknown = fx(`${network}-error-unknown-coin`), badType = fx(`${network}-error-bad-type`), badUser = fx(`${network}-error-bad-user`);227    expect([unknown.status, unknown.body]).toEqual([500, null]);228    expect([badType.status, badUser.status]).toEqual([422, 422]);229    expect(badType.body).toBe('Failed to deserialize the JSON body into the target type');230    // the adapter never sends an unknown coin or a malformed address: both are refused locally231    const { adapter, calls } = make(network);232    expect(await code(adapter.book('NOT-A-COIN'))).toBe('unsupported');233    expect(calls.map((c) => c.body.type)).toEqual(['meta']);234    expect(await code(adapter.account('not-an-address'))).toBe('unsupported');235    expect(calls.length).toBe(1);236    // and if the venue answers that way anyway237    const a = make(network, { l2Book: { status: unknown.status, body: 'null' } });238    expect(await code(a.adapter.book('BTC'))).toBe('unavailable');239    const b = make(network, { clearinghouseState: { status: 422, body: badUser.body } });240    expect(await code(b.adapter.account(account))).toBe('bad_answer');241  });242});243244describe('hyperliquid read side — malformed, delayed and rate-limited answers', () => {245  const account = (fx('mainnet-clearinghouseState').request as { user: string }).user;246  const patched = (name: string, patch: (b: any) => void) => { const b = structuredClone(fx(`mainnet-${name}`).body); patch(b); return { body: b }; };247248  it.each([249    ['meta without szDecimals', 'instruments', { meta: patched('meta', (b) => { delete b.universe[0].szDecimals; }) }],250    ['meta with a float leverage', 'instruments', { meta: patched('meta', (b) => { b.universe[0].maxLeverage = 2.5; }) }],251    ['a price sent as a JSON number', 'book', { l2Book: patched('l2Book-BTC', (b) => { b.levels[0][0].px = 73699.5; }) }],252    ['a price in exponent notation', 'book', { l2Book: patched('l2Book-BTC', (b) => { b.levels[0][0].px = '7.3e4'; }) }],253    ['a crossed book', 'book', { l2Book: patched('l2Book-BTC', (b) => { b.levels[0][0].px = '999999999'; }) }],254    ['bids out of order', 'book', { l2Book: patched('l2Book-BTC', (b) => { b.levels[0].reverse(); }) }],255    ['a book for another coin', 'book', { l2Book: patched('l2Book-BTC', (b) => { b.coin = 'ETH'; }) }],256    ['a book without time', 'book', { l2Book: patched('l2Book-BTC', (b) => { delete b.time; }) }],257    ['contexts shorter than the universe', 'tickers', { metaAndAssetCtxs: patched('metaAndAssetCtxs', (b) => { b[1].pop(); }) }],258    ['a context without mark price', 'tickers', { metaAndAssetCtxs: patched('metaAndAssetCtxs', (b) => { delete b[1][0].markPx; }) }],259    ['a trade with an unknown side', 'trades', { recentTrades: patched('recentTrades-BTC', (b) => { b[0].side = 'X'; }) }],260    ['a candle with low above high', 'candles', { candleSnapshot: patched('candleSnapshot-BTC-1h', (b) => { b[0].l = '999999999'; }) }],261    ['an account state without withdrawable', 'account', { clearinghouseState: patched('clearinghouseState', (b) => { delete b.withdrawable; }) }],262    ['a position with a non-numeric size', 'positions', { clearinghouseState: patched('clearinghouseState', (b) => { b.assetPositions[0].position.szi = 'NaN'; }) }],263    ['an order with an unknown status', 'orderHistory', { historicalOrders: patched('historicalOrders', (b) => { b[0].status = 'somethingNew'; }) }],264    ['a fill without a trade id', 'fills', { userFills: patched('userFills', (b) => { delete b[0].tid; }) }],265    ['a funding payment without rate', 'funding', { userFunding: patched('userFunding', (b) => { delete b[0].delta.fundingRate; }) }],266    ['an HTML page', 'instruments', { meta: { body: '<html>maintenance</html>' } }],267    ['an empty body', 'instruments', { meta: { body: '' } }],268    ['null', 'tickers', { metaAndAssetCtxs: { body: 'null' } }],269  ] as [string, string, Record<string, Route>][])('%s → bad_answer', async (_n, method, extra) => {270    const { adapter } = make('mainnet', extra);271    const call: Record<string, () => Promise<unknown>> = {272      instruments: () => adapter.instruments(), tickers: () => adapter.tickers(), book: () => adapter.book('BTC'), trades: () => adapter.trades('BTC'), candles: () => adapter.candles('BTC', '1h', {}),273      account: () => adapter.account(account), positions: () => adapter.positions(account), orderHistory: () => adapter.orderHistory(account), fills: () => adapter.fills(account), funding: () => adapter.funding(account),274    };275    expect(await code(call[method]!())).toBe('bad_answer');276  });277278  it('unknown fields are tolerated', async () => {279    const { adapter } = make('mainnet', { l2Book: patched('l2Book-BTC', (b) => { b.somethingNew = { a: 1 }; b.levels[0][0].extra = true; }) });280    expect((await adapter.book('BTC')).bids.length).toBeGreaterThan(0);281  });282283  it('an order type that cannot be represented is not hidden: unsupported', async () => {284    const { adapter } = make('mainnet', { frontendOpenOrders: patched('frontendOpenOrders', (b) => { b[0].orderType = 'Trailing Stop'; }) });285    expect(await code(adapter.openOrders(account))).toBe('unsupported');286  });287288  it('a delayed answer is cut at the timeout: unavailable', async () => {289    const fetchImpl = ((_u: string, init: RequestInit) => new Promise((_res, rej) => { init.signal!.addEventListener('abort', () => rej(init.signal!.reason)); })) as unknown as typeof fetch;290    const adapter = createHyperliquidAdapter({ network: 'mainnet', fetch: fetchImpl, timeoutMs: 30 });291    const t0 = Date.now();292    try { await adapter.instruments(); expect.unreachable(); }293    catch (e) { expect(e).toBeInstanceOf(VenueError); expect((e as VenueError).code).toBe('unavailable'); expect(((e as VenueError).detail as Record<string, any>).timeout).toBe(true); }294    expect(Date.now() - t0).toBeLessThan(2000);295  });296297  it('a network failure and an HTTP 502 are unavailable', async () => {298    const down = createHyperliquidAdapter({ network: 'mainnet', fetch: (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch });299    expect(await code(down.instruments())).toBe('unavailable');300    expect(await code(make('mainnet', { meta: { status: 502, body: 'Bad Gateway' } }).adapter.instruments())).toBe('unavailable');301  });302303  it('HTTP 429 is rate_limited and closes the local budget for a minute', async () => {304    const { adapter, advance } = make('mainnet', { meta: { status: 429, body: '' } });305    try { await adapter.instruments(); expect.unreachable(); }306    catch (e) { expect((e as VenueError).code).toBe('rate_limited'); expect(((e as VenueError).detail as Record<string, any>).retryAfterMs).toBe(60_000); }307    expect(adapter.http.budget.used()).toBe(1200);308    try { await adapter.book('BTC'); expect.unreachable(); }309    catch (e) { expect((e as VenueError).code).toBe('rate_limited'); expect(((e as VenueError).detail as Record<string, any>).local).toBe(true); }310    advance(60_001);311    expect(adapter.http.budget.used()).toBe(0);312  });313314  it('documented weights: 2 / 20 / 60, per-item surcharges, exchange batches', () => {315    for (const t of ['l2Book', 'allMids', 'clearinghouseState', 'orderStatus', 'spotClearinghouseState', 'exchangeStatus']) expect(hlInfoWeight(t)).toBe(2);316    for (const t of ['meta', 'metaAndAssetCtxs', 'recentTrades', 'candleSnapshot', 'frontendOpenOrders', 'historicalOrders', 'userFills', 'userFunding', 'fundingHistory']) expect(hlInfoWeight(t)).toBe(20);317    expect(hlInfoWeight('userRole')).toBe(60);318    expect(hlInfoExtraWeight('userFills', 2000)).toBe(100);319    expect(hlInfoExtraWeight('recentTrades', 19)).toBe(0);320    expect(hlInfoExtraWeight('candleSnapshot', 5000)).toBe(83);321    expect(hlInfoExtraWeight('meta', 5000)).toBe(0);322    expect([hlExchangeWeight(1), hlExchangeWeight(39), hlExchangeWeight(40), hlExchangeWeight(79), hlExchangeWeight(80)]).toEqual([1, 1, 2, 2, 3]);323  });324325  it('the local budget refuses the request that would pass 1200 per minute, before sending it', async () => {326    const { adapter, calls, advance } = make('mainnet');327    for (let i = 0; i < 60; i++) await adapter.instruments(); // 60 × 20 = 1200328    expect(calls.length).toBe(60);329    try { await adapter.instruments(); expect.unreachable(); }330    catch (e) { expect((e as VenueError).code).toBe('rate_limited'); expect(((e as VenueError).detail as Record<string, any>).retryAfterMs).toBeGreaterThan(0); }331    expect(calls.length).toBe(60);332    advance(60_001);333    await adapter.instruments();334    expect(calls.length).toBe(61);335  });336337  it('per-item surcharge is charged after the answer', async () => {338    const { adapter } = make('mainnet');339    await adapter.fills(account); // 20 + floor(40 / 20)340    expect(adapter.http.budget.used()).toBe(22);341    const b = new HlWeightBudget(100, () => 0);342    b.take(90, 'x');343    expect(b.retryAfterMs(10)).toBe(0);344    expect(b.retryAfterMs(11)).toBe(60_000);345  });346347  it('order status strings of the documentation', () => {348    expect(hlOrderStatus('open').status).toBe('open');349    expect(hlOrderStatus('filled').status).toBe('filled');350    expect(hlOrderStatus('canceled').status).toBe('cancelled');351    expect(hlOrderStatus('triggered').status).toBe('triggered');352    expect(hlOrderStatus('rejected')).toEqual({ status: 'rejected', rejectReason: 'rejected' });353    for (const s of ['marginCanceled', 'vaultWithdrawalCanceled', 'openInterestCapCanceled', 'selfTradeCanceled', 'reduceOnlyCanceled', 'siblingFilledCanceled', 'delistedCanceled', 'liquidatedCanceled'])354      expect(hlOrderStatus(s)).toEqual({ status: 'cancelled', rejectReason: s });355    expect(hlOrderStatus('tickRejected')).toEqual({ status: 'rejected', rejectReason: 'tickRejected' });356  });357});358
Files main
Menu

Theme