SPB Git

Repository Public

derivatives-helpers.ts 8.5 KB 125 lines typescript

1// Fixture-backed venue adapters for the derivatives tests: the REAL adapters of @spw/derivatives over a replaying fetch2// (answers recorded from the venues' public endpoints, packages/derivatives/test/fixtures/), wrapped so that a test can script3// the Hyperliquid BTC ticker, book and funding rates, cut a venue off, and drive a fake stream. No network.4import { useTestDatabase } from './test-db.ts';5import { readFileSync } from 'node:fs';6import { createDydxAdapter, createGmxAdapter, createHyperliquidAdapter, VenueError, type Book, type DecStr, type Ticker, type VenueAdapter } from '@spw/derivatives';7import { fx, replay, type Route } from '../../../packages/derivatives/test/venue-helpers.ts';89/** just after the fixtures were recorded */10export const T0 = Date.parse('2026-11-11T03:05:30Z');11export const BTC = 'hyperliquid:BTC';12export const GMX_BTC = 'gmx:BTC/USD [BTC-USDC]';13export const DYDX_BTC = 'dydx:BTC-USD';1415const hlFx = (name: string): { status: number; body: unknown; request: { user?: string } } =>16  JSON.parse(readFileSync(new URL(`../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-${name}.json`, import.meta.url), 'utf8'));17export const HL_ACCOUNT = hlFx('clearinghouseState').request.user!;18export const GMX_ACCOUNT = '0x46a3BA57e146A4906bc68759Fb7ad1E74d3B66f6';1920export class Clock { t: number; constructor(t = T0) { this.t = t; } now = (): number => this.t; advance(ms: number): number { this.t += ms; return this.t; } }2122export interface FakeStream {23  subs: { type: string; symbol: string; on: (ev: unknown) => void; onStatus: (s: 'connected' | 'reconnecting' | 'stale') => void; open: boolean }[];24  open(): FakeStream['subs']; emit(ev: unknown): void; status(s: 'connected' | 'reconnecting' | 'stale'): void;25}26function fakeStream(): FakeStream & { stream: NonNullable<VenueAdapter['stream']> } {27  const subs: FakeStream['subs'] = [];28  const open = () => subs.filter((s) => s.open);29  return {30    subs, open,31    emit: (ev) => { for (const s of open()) if ((ev as { type: string }).type === s.type || (ev as { type: string }).type === 'gap') s.on(ev); },32    status: (st) => { for (const s of open()) s.onStatus(st); },33    stream: (sub, on, onStatus) => {34      if (sub.type === 'account') throw new Error('account streams are not used');35      const s = { type: sub.type, symbol: sub.symbol, on, onStatus, open: true };36      subs.push(s);37      return () => { s.open = false; };38    },39  };40}4142export interface Venues {43  adapters: { hyperliquid: VenueAdapter; gmx: VenueAdapter; dydx: VenueAdapter };44  /** scripted Hyperliquid BTC market (null = the recorded fixture) */45  hl: { ticker: Partial<Ticker> | null; book: { bids: [DecStr, DecStr][]; asks: [DecStr, DecStr][] } | null; funding: { ts: number; rate: DecStr }[] | null;46    /** how old the venue's own book timestamp is (0 = stamped now) */ bookAgeMs: number;47    down: boolean; /** the adapter's local weight budget refuses every request */ rateLimited: boolean; calls: Record<string, number>; stream: FakeStream };48  dydx: { accountCalls: number; stream: FakeStream };49  gmx: { down: boolean };50}5152export function fixtureVenues(clock: Clock): Venues {53  const now = clock.now;54  const HL: Record<string, string> = { meta: 'meta', metaAndAssetCtxs: 'metaAndAssetCtxs', l2Book: 'l2Book-BTC', recentTrades: 'recentTrades-BTC', candleSnapshot: 'candleSnapshot-BTC-1h',55    fundingHistory: 'fundingHistory-BTC', clearinghouseState: 'clearinghouseState', frontendOpenOrders: 'frontendOpenOrders' };56  const hlState: Venues['hl'] = { ticker: null, book: null, funding: null, bookAgeMs: 0, down: false, rateLimited: false, calls: {}, stream: null as never };57  const hlFetch = (async (url: string, init: RequestInit) => {58    const body = JSON.parse(String(init.body)) as { type: string };59    if (String(url).endsWith('/exchange')) throw new Error('the trading endpoint must never be called');60    hlState.calls[body.type] = (hlState.calls[body.type] ?? 0) + 1;61    const name = HL[body.type];62    if (!name) throw new Error(`no fixture for ${body.type}`);63    const f = hlFx(name);64    return new Response(JSON.stringify(f.body), { status: f.status, headers: { 'content-type': 'application/json' } });65  }) as unknown as typeof fetch;66  const hlReal = createHyperliquidAdapter({ network: 'mainnet', fetch: hlFetch, now });67  const hlStream = fakeStream(), dydxStream = fakeStream();68  hlState.stream = hlStream;69  const guard = (): void => {70    if (hlState.rateLimited) throw new VenueError('rate_limited', 'hyperliquid: local weight budget exhausted (1200/1200 per minute) before info metaAndAssetCtxs', { venue: 'hyperliquid' });71    if (hlState.down) throw new VenueError('unavailable', 'hyperliquid: network error (scripted outage)', { venue: 'hyperliquid' });72  };73  const hyperliquid: VenueAdapter = {74    ...hlReal, stream: hlStream.stream,75    instruments: async () => { guard(); return hlReal.instruments(); },76    tickers: async () => { guard(); const all = await hlReal.tickers(); return all.map((t) => (t.instrument === BTC && hlState.ticker ? { ...t, ...hlState.ticker, ts: now() } : t)); },77    book: async (symbol, depth) => {78      guard();79      if (symbol === 'BTC' && hlState.book) return { instrument: BTC, bids: hlState.book.bids, asks: hlState.book.asks, ts: now() - hlState.bookAgeMs };80      return { ...(await hlReal.book(symbol, depth)), ts: now() - hlState.bookAgeMs };      // the recorded book, stamped with the test clock81    },82    trades: async (s, l) => { guard(); return hlReal.trades(s, l); },83    candles: async (s, i, o) => { guard(); return hlReal.candles(s, i, o); },84    fundingHistory: async (s, o) => { guard(); return hlState.funding ? hlState.funding.filter((x) => x.ts >= o.start).map((x) => ({ ...x, premium: null })) : hlReal.fundingHistory(s, o); },85  };8687  const gmxState = { down: false };88  const GMX_ROUTES: Route[] = [89    ['/markets/tickers', fx('gmx', 'v1-markets-tickers')], ['/markets/config', fx('gmx', 'v1-markets-config')], [/\/v1\/markets$/, fx('gmx', 'v1-markets')],90    ['/tokens/info', fx('gmx', 'v1-tokens-info')], ['/pairs', fx('gmx', 'v1-pairs')], ['/prices/ohlcv', fx('gmx', 'v1-ohlcv-BTC-1h')], ['/rates', fx('gmx', 'v1-rates-BTC-7d')],91    ['squids.live', fx('gmx', 'graphql-tradeActions-BTC')], ['/positions', fx('gmx', 'v1-positions')], ['/orders', fx('gmx', 'v1-orders')], ['/trades', fx('gmx', 'v1-trades')],92  ];93  const gmxReplay = replay(GMX_ROUTES);94  const gmxFetch = (async (url: string, init?: RequestInit) => { if (gmxState.down) return new Response('bad gateway', { status: 502 }); return gmxReplay.fetch(url, init); }) as unknown as typeof fetch;95  const gmx = createGmxAdapter({ fetch: gmxFetch, now, cacheMs: 0 });9697  const dydxState = { accountCalls: 0, stream: dydxStream };98  const DYDX_ROUTES: Route[] = [99    [/\/v4\/perpetualMarkets$/, fx('dydx', 'perpetualMarkets')], ['/orderbooks/perpetualMarket/BTC-USD', fx('dydx', 'orderbook-BTC-USD')], ['/trades/perpetualMarket/', fx('dydx', 'trades-BTC-USD')],100    ['/candles/perpetualMarkets/', fx('dydx', 'candles-BTC-USD-1HOUR')], ['/historicalFunding/', fx('dydx', 'historicalFunding-BTC-USD')],101  ];102  const dydxReplay = replay(DYDX_ROUTES);103  const dydxFetch = (async (url: string, init?: RequestInit) => {104    if (/\/addresses\/|\/orders|\/fills|\/fundingPayments/.test(String(url))) { dydxState.accountCalls += 1; throw new Error('a dYdX account endpoint must never be called'); }105    return dydxReplay.fetch(url, init);106  }) as unknown as typeof fetch;107  const dydx: VenueAdapter = { ...createDydxAdapter({ fetch: dydxFetch, now }), stream: dydxStream.stream };108109  return { adapters: { hyperliquid, gmx, dydx }, hl: hlState, dydx: dydxState, gmx: gmxState };110}111112/** a simple BTC market around `mid`: one level each side 5 away, a second one 10 away */113export function setMarket(v: Venues, mid: number, o: { bids?: [DecStr, DecStr][]; asks?: [DecStr, DecStr][] } = {}): void {114  v.hl.ticker = { mark: String(mid), index: String(mid), mid: String(mid), funding: '0.0000125' };115  v.hl.book = { bids: o.bids ?? [[String(mid - 5), '1'], [String(mid - 10), '2']], asks: o.asks ?? [[String(mid + 5), '1'], [String(mid + 10), '2']] };116}117118/** creates the database if needed (like the market tests) and points the API's environment at it; call before importing src/ */119export async function useDatabase(label: string): Promise<void> {120  // v8: `label` names the test file; the database itself is unique per checkout and per run (test-db.ts) and121  // is dropped by vitest's global teardown — two checkouts running the suite at once no longer share anything122  await useTestDatabase(label);123}124export type { Book };125
Files main
Menu

Theme