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