SPB Git

Repository Public

stream.test.ts 18.4 KB 288 lines typescript

1// dYdX indexer websocket: real frames recorded from mainnet (public channels) and testnet (subaccount channel), replayed2// through a scripted socket. Covers snapshot + deltas, out-of-order ids, duplicate delivery, gaps, reconnect and resubscribe.3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';4import { createDydxAdapter, D, DydxStream, type DydxStreamEvent, type WebSocketCtor } from '../../src/index.ts';5import { FakeSocket, wsFx } from '../venue-helpers.ts';67const pub = wsFx('dydx', 'ws-mainnet-public'), acct = wsFx('dydx', 'ws-testnet-subaccount');8const WS = FakeSocket as unknown as WebSocketCtor;9const connected = (id = 0) => ({ type: 'connected', connection_id: 'c', message_id: id });10const frames = (channel: string, type?: string) => pub.messages.filter((m) => m.channel === channel && (!type || m.type === type));11const bookSnapshot = frames('v4_orderbook', 'subscribed')[0]!, bookDeltas = frames('v4_orderbook', 'channel_data');12const tradesSnapshot = frames('v4_trades', 'subscribed')[0]!, marketsSnapshot = frames('v4_markets', 'subscribed')[0]!;13let clock = 0;14const open = (o: Partial<ConstructorParameters<typeof DydxStream>[0]> = {}) => new DydxStream({ url: 'wss://example.invalid/v4/ws', WebSocket: WS, now: () => clock, ...o });1516beforeEach(() => { vi.useFakeTimers(); FakeSocket.reset(); clock = 1_000_000; });17afterEach(() => { vi.useRealTimers(); });1819describe('dYdX stream: recorded mainnet frames', () => {20  it('the recording really has out-of-order message ids (why order is not enforced)', () => {21    const ids = pub.messages.map((m) => m.message_id as number).filter((x) => x !== undefined);22    expect(ids.some((id, i) => i > 0 && id < ids[i - 1]!)).toBe(true);23    expect(new Set(ids).size).toBe(ids.length);24  });2526  it('replays the whole recording: book snapshot + 150 deltas, trades, markets — no gap, no reconnect', () => {27    const s = open(), events: DydxStreamEvent[] = [], status: string[] = [];28    const on = (e: DydxStreamEvent) => events.push(e), st = (x: string) => status.push(x);29    s.subscribe('v4_orderbook', 'BTC-USD', null, on, st);30    s.subscribe('v4_trades', 'BTC-USD', null, on, st);31    s.subscribe('v4_markets', null, 'BTC-USD', on, st);32    s.subscribe('v4_orderbook', 'NOPE-USD', null, on, st);33    const sock = FakeSocket.last;34    expect(sock.sent).toEqual([]);                               // nothing is sent before the server says `connected`35    for (const m of pub.messages) sock.serve(m);36    expect(sock.subscriptions()).toEqual(['v4_orderbook|BTC-USD', 'v4_trades|BTC-USD', 'v4_markets|', 'v4_orderbook|NOPE-USD']);37    expect(sock.sent.every((m) => m.type !== 'subscribe' || m.batched === true)).toBe(true);38    vi.advanceTimersByTime(6_000);                               // past the gap window: every id arrived, so no stale39    expect(FakeSocket.instances.length).toBe(1);40    expect(new Set(status)).toEqual(new Set(['connected']));4142    const books = events.filter((e) => e.type === 'book');43    expect(books.length).toBe(1 + bookDeltas.length);44    expect(books[0]).toMatchObject({ symbol: 'BTC-USD', snapshot: true });45    const last = books.at(-1)!;46    if (last.type !== 'book') throw new Error('unreachable');47    expect(last.book.instrument).toBe('dydx:BTC-USD');48    expect(last.book.bids.length).toBeGreaterThan(10);49    for (let i = 1; i < last.book.bids.length; i++) expect(D.cmp(D.from(last.book.bids[i - 1]![0]), D.from(last.book.bids[i]![0]))).toBe(1);50    for (let i = 1; i < last.book.asks.length; i++) expect(D.cmp(D.from(last.book.asks[i - 1]![0]), D.from(last.book.asks[i]![0]))).toBe(-1);51    for (const [, size] of [...last.book.bids, ...last.book.asks]) expect(D.isZero(D.from(size))).toBe(false);   // removed levels are gone5253    const trades = events.filter((e) => e.type === 'trades');54    expect(trades[0]).toMatchObject({ symbol: 'BTC-USD', snapshot: true });55    if (trades[0]!.type === 'trades') expect(trades[0]!.trades.length).toBe((tradesSnapshot.contents as any).trades.length);5657    const tick = events.find((e) => e.type === 'ticker');58    if (tick?.type !== 'ticker') throw new Error('no ticker');59    expect(tick.ticker).toMatchObject({ instrument: 'dydx:BTC-USD', mid: null });60    expect(tick.ticker.mark).toBe((marketsSnapshot.contents as any).markets['BTC-USD'].oraclePrice);61    expect(events.filter((e) => e.type === 'ticker').every((e) => e.type === 'ticker' && e.symbol === 'BTC-USD')).toBe(true);6263    // the invalid subscription is reported to its own subscriber only64    expect(events.filter((e) => e.type === 'error')).toEqual([{ type: 'error', message: 'Invalid subscription id for channel: (v4_orderbook-NOPE-USD)' }]);65  });6667  it('applies a delta exactly: a level is set, then removed by size 0', () => {68    const s = open(), books: any[] = [];69    s.subscribe('v4_orderbook', 'BTC-USD', null, (e) => { if (e.type === 'book') books.push(e.book); }, () => {});70    const sock = FakeSocket.last;71    sock.serve(connected());72    sock.serve({ type: 'subscribed', message_id: 1, channel: 'v4_orderbook', id: 'BTC-USD', contents: { bids: [{ price: '100', size: '1' }, { price: '99', size: '2' }], asks: [{ price: '101', size: '3' }] } });73    sock.serve({ type: 'channel_batch_data', message_id: 2, channel: 'v4_orderbook', id: 'BTC-USD', contents: [{ bids: [['99.5', '0.250', '7']] }, { asks: [['101', '0']] }, { asks: [['102.0', '4']] }] });74    expect(books.at(-1)).toMatchObject({ bids: [['100', '1'], ['99.5', '0.25'], ['99', '2']], asks: [['102', '4']] });75    sock.serve({ type: 'channel_data', message_id: 3, channel: 'v4_orderbook', id: 'BTC-USD', contents: { bids: [['100.00', '0']] } });76    expect(books.at(-1).bids).toEqual([['99.5', '0.25'], ['99', '2']]);77  });7879  it('flags a crossed book instead of hiding it', () => {80    const s = open(), ev: any[] = [];81    s.subscribe('v4_orderbook', 'BTC-USD', null, (e) => ev.push(e), () => {});82    FakeSocket.last.serve(connected());83    FakeSocket.last.serve({ type: 'subscribed', message_id: 1, channel: 'v4_orderbook', id: 'BTC-USD', contents: { bids: [{ price: '101', size: '1' }], asks: [{ price: '100', size: '1' }] } });84    expect(ev[0]).toMatchObject({ crossed: true });85  });8687  it('markets channel: partial updates are merged, the ticker is re-emitted only for the watched symbol', () => {88    const s = open(), ticks: any[] = [];89    s.subscribe('v4_markets', null, 'BTC-USD', (e) => { if (e.type === 'ticker') ticks.push(e); }, () => {});90    const sock = FakeSocket.last;91    sock.serve(connected());92    sock.serve({ ...marketsSnapshot, message_id: 1 });93    expect(ticks.length).toBe(1);94    sock.serve({ type: 'channel_batch_data', message_id: 2, channel: 'v4_markets', contents: [{ oraclePrices: { 'ETH-USD': { oraclePrice: '2000.5', effectiveAt: 'x' } } }] });95    expect(ticks.length).toBe(1);96    sock.serve({ type: 'channel_batch_data', message_id: 3, channel: 'v4_markets', contents: [{ oraclePrices: { 'BTC-USD': { oraclePrice: '70000.123456789', effectiveAt: 'x' } } }, { trading: { 'BTC-USD': { openInterest: '250.5', nextFundingRate: '-0.00001' } } }] });97    expect(ticks.length).toBe(3);98    expect(ticks.at(-1)).toMatchObject({ snapshot: false, ticker: { mark: '70000.123456789', index: '70000.123456789', openInterest: '250.5', funding: '-0.00001' } });99  });100});101102describe('dYdX stream: duplicates, gaps, reconnect', () => {103  const start = () => {104    const s = open(), events: DydxStreamEvent[] = [], status: string[] = [];105    const off = s.subscribe('v4_trades', 'BTC-USD', null, (e) => events.push(e), (x) => status.push(x));106    const sock = FakeSocket.last;107    sock.serve(connected());108    sock.serve({ ...tradesSnapshot, message_id: 1 });109    return { s, events, status, off, sock };110  };111  const trade = (id: string, at = '2026-11-11T03:00:00.000Z') => ({ id, side: 'BUY', size: '0.001', price: '73500', type: 'LIMIT', createdAt: at, createdAtHeight: '1' });112113  it('a frame delivered twice (same message id) is applied once', () => {114    const { events, sock } = start();115    const upd = { type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('new-1')] } };116    sock.serve(upd); sock.serve(upd); sock.serve({ ...tradesSnapshot, message_id: 1 });117    expect(events.filter((e) => e.type === 'trades').length).toBe(2);118    expect(events.at(-1)).toMatchObject({ type: 'trades', snapshot: false, trades: [{ id: 'new-1', price: '73500', size: '0.001', side: 'buy' }] });119  });120121  it('the same trade under a new message id is not emitted again', () => {122    const { events, sock } = start();123    sock.serve({ type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('new-1')] } });124    sock.serve({ type: 'channel_data', message_id: 3, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('new-1'), trade('new-2')] } });125    sock.serve({ type: 'channel_data', message_id: 4, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('new-2')] } });126    const upd = events.filter((e) => e.type === 'trades' && !e.snapshot) as Extract<DydxStreamEvent, { type: 'trades' }>[];127    expect(upd.map((e) => e.trades.map((t) => t.id))).toEqual([['new-1'], ['new-2']]);128  });129130  it('ids that arrive out of order within the window are fine; an id still missing after the window is a gap → stale → reconnect', () => {131    const { status, sock } = start();132    sock.serve({ type: 'channel_data', message_id: 4, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('a')] } });133    sock.serve({ type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('b')] } });134    sock.serve({ type: 'channel_data', message_id: 3, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('c')] } });135    vi.advanceTimersByTime(10_000);136    expect(status).toEqual(['connected']);137    expect(FakeSocket.instances.length).toBe(1);138139    sock.serve({ type: 'channel_data', message_id: 7, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('d')] } });   // 5 and 6 never come140    vi.advanceTimersByTime(4_999);141    expect(status).toEqual(['connected']);142    vi.advanceTimersByTime(1);143    expect(status).toEqual(['connected', 'stale', 'reconnecting']);144    expect(sock.closed).toBe(true);145    vi.advanceTimersByTime(500);                                 // first backoff step146    expect(FakeSocket.instances.length).toBe(2);147  });148149  it('reconnects with backoff, resubscribes everything, and does not replay trades already delivered', () => {150    const { s, events, status, sock } = start();151    const books: any[] = [];152    s.subscribe('v4_orderbook', 'ETH-USD', null, (e) => { if (e.type === 'book') books.push(e); }, () => {});153    expect(sock.subscriptions()).toEqual(['v4_trades|BTC-USD', 'v4_orderbook|ETH-USD']);154    sock.serve({ type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('live-1')] } });155    const before = events.length;156157    sock.drop();                                                 // connection lost158    expect(status.at(-1)).toBe('reconnecting');159    vi.advanceTimersByTime(499);160    expect(FakeSocket.instances.length).toBe(1);161    vi.advanceTimersByTime(1);162    const second = FakeSocket.last;163    expect(FakeSocket.instances.length).toBe(2);164    second.drop();                                               // fails again before `connected`: the delay doubles165    vi.advanceTimersByTime(999);166    expect(FakeSocket.instances.length).toBe(2);167    vi.advanceTimersByTime(1);168    const third = FakeSocket.last;169    expect(FakeSocket.instances.length).toBe(3);170    expect(third.sent).toEqual([]);171    third.serve(connected(0));172    expect(third.subscriptions()).toEqual(['v4_trades|BTC-USD', 'v4_orderbook|ETH-USD']);173    expect(status.at(-1)).toBe('connected');174175    // message ids restart on the new connection; the snapshot repeats trades we already delivered plus one new176    const snap = structuredClone(tradesSnapshot) as any;177    snap.message_id = 1;178    snap.contents.trades = [trade('after-gap', '2026-11-11T03:05:00.000Z'), trade('live-1'), ...snap.contents.trades];179    third.serve(snap);180    const fresh = events.slice(before);181    expect(fresh).toEqual([{ type: 'trades', symbol: 'BTC-USD', snapshot: true, trades: [expect.objectContaining({ id: 'after-gap' })] }]);182    // deltas that arrive before the new snapshot of a channel are not applied to a stale book183    third.serve({ type: 'channel_data', message_id: 2, channel: 'v4_orderbook', id: 'ETH-USD', contents: { bids: [['2000', '1']] } });184    expect(books.length).toBe(0);185    third.serve({ type: 'subscribed', message_id: 3, channel: 'v4_orderbook', id: 'ETH-USD', contents: { bids: [{ price: '2000', size: '1' }], asks: [] } });186    expect(books.length).toBe(1);187  });188189  it('silence is detected: no frame for 40 s → stale, then reconnect; pings are sent meanwhile', () => {190    const { status, sock } = start();191    for (let i = 0; i < 2; i++) { clock += 15_000; vi.advanceTimersByTime(15_000); }192    expect(sock.sent.filter((m) => m.type === 'ping').length).toBe(2);193    expect(status).toEqual(['connected']);194    clock += 15_000; vi.advanceTimersByTime(15_000);             // 45 s without any frame195    expect(status).toEqual(['connected', 'stale', 'reconnecting']);196    clock += 500; vi.advanceTimersByTime(500);197    expect(FakeSocket.instances.length).toBe(2);198  });199200  it('a pong keeps the connection alive', () => {201    const { status, sock } = start();202    for (let i = 0; i < 6; i++) { clock += 15_000; vi.advanceTimersByTime(15_000); sock.serve({ type: 'pong', connection_id: 'c', message_id: 2 + i }); }203    expect(status).toEqual(['connected']);204    expect(FakeSocket.instances.length).toBe(1);205  });206207  it('an unreadable or malformed frame is treated as a gap, not ignored', () => {208    const a = start();209    a.sock.serve('not json');210    expect(a.status).toEqual(['connected', 'stale', 'reconnecting']);211    FakeSocket.reset();212    const b = start();213    b.sock.serve({ type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [{ id: 'x', side: 'BUY', size: 1, price: '1', type: 'LIMIT', createdAt: '2026-11-11T03:00:00.000Z', createdAtHeight: '1' }] } });214    expect(b.status).toEqual(['connected', 'stale', 'reconnecting']);215  });216217  it('unsubscribing tells the server, and the last unsubscribe closes the socket and stops every timer', () => {218    const { s, off, sock, events } = start();219    const off2 = s.subscribe('v4_orderbook', 'BTC-USD', null, () => {}, () => {});220    off();221    expect(sock.sent.at(-1)).toEqual({ type: 'unsubscribe', channel: 'v4_trades', id: 'BTC-USD' });222    const n = events.length;223    sock.serve({ type: 'channel_data', message_id: 2, channel: 'v4_trades', id: 'BTC-USD', contents: { trades: [trade('late')] } });224    expect(events.length).toBe(n);225    off2();226    expect(sock.closed).toBe(true);227    expect(vi.getTimerCount()).toBe(0);228    vi.advanceTimersByTime(120_000);229    expect(FakeSocket.instances.length).toBe(1);230  });231232  it('two subscribers of one channel share one server subscription; a late joiner gets the current book', async () => {233    const s = open(), a: any[] = [], b: any[] = [];234    s.subscribe('v4_orderbook', 'BTC-USD', null, (e) => a.push(e), () => {});235    const sock = FakeSocket.last;236    sock.serve(connected());237    sock.serve({ ...bookSnapshot, message_id: 1 });238    s.subscribe('v4_orderbook', 'BTC-USD', null, (e) => b.push(e), () => {});239    await Promise.resolve();240    expect(sock.subscriptions()).toEqual(['v4_orderbook|BTC-USD']);241    expect(b.length).toBe(1);242    expect(b[0]).toMatchObject({ type: 'book', snapshot: true });243    expect(b[0].book.bids).toEqual(a[0].book.bids);244  });245});246247describe('dYdX stream: subaccount channel (recorded testnet frames) through the adapter', () => {248  it('snapshot carries the subaccount and its open orders; updates carry changed orders', () => {249    const a = createDydxAdapter({ network: 'testnet', WebSocket: WS, now: () => clock });250    const id = (acct.sent[0] as any).id as string, events: any[] = [], status: string[] = [];251    const off = a.stream({ type: 'account', account: id }, (e) => events.push(e), (s) => status.push(s));252    const sock = FakeSocket.last;253    expect(sock.url).toBe('wss://indexer.v4testnet.dydx.exchange/v4/ws');254    for (const m of acct.messages) sock.serve(m);255    expect(sock.subscriptions()).toEqual([`v4_subaccounts|${id}`]);256    expect(status).toEqual(['connected']);257    expect(events[0]).toMatchObject({ type: 'account', account: id, snapshot: true, hasFills: false });258    expect(events[0].subaccount.equity).toMatch(/^\d+(\.\d+)?$/);259    const snapOrders = (acct.messages[1]!.contents as any).orders as any[];260    expect(events[0].orders.map((o: any) => o.venueOrderId)).toEqual(snapOrders.map((o) => o.id));261    const updates = events.slice(1);262    expect(updates.length).toBe(acct.messages.filter((m) => m.type === 'channel_data' || m.type === 'channel_batch_data').length);263    // the recorded updates are partial objects of three different shapes (cancel with optimistic fill, cancel with final fill, placement)264    const ups = updates.flatMap((u) => u.orderUpdates);265    expect(ups.length).toBe(updates.length);266    expect(updates.every((u) => u.orders.length === 0 && u.subaccount === null && u.blockHeight !== null)).toBe(true);267    expect(new Set(ups.map((u: any) => u.venueStatus))).toEqual(new Set(['CANCELED', 'OPEN']));268    expect(ups.every((u: any) => u.instrument === 'dydx:TAO-USD' && /^\d+$/.test(u.clientId))).toBe(true);269    expect(ups.find((u: any) => u.venueStatus === 'OPEN').filled).toBeNull();          // a placement carries no fill figure270    expect(ups.find((u: any) => u.venueOrderId === '33914979-282d-535a-a907-bfb90982f4d3').filled).toBe('0.11');271    expect(ups.find((u: any) => u.venueStatus === 'CANCELED').removalReason).toBe('ORDER_REMOVAL_REASON_EXPIRED');272    off();273    expect(sock.closed).toBe(true);274  });275276  it('book / trades / ticker subscriptions map to the indexer channels', () => {277    const a = createDydxAdapter({ WebSocket: WS, now: () => clock });278    a.stream({ type: 'book', symbol: 'BTC-USD' }, () => {}, () => {});279    a.stream({ type: 'trades', symbol: 'ETH-USD' }, () => {}, () => {});280    a.stream({ type: 'ticker', symbol: 'BTC-USD' }, () => {}, () => {});281    expect(FakeSocket.instances.length).toBe(1);                 // one shared connection282    FakeSocket.last.serve(connected());283    expect(FakeSocket.last.url).toBe('wss://indexer.dydx.trade/v4/ws');284    expect(FakeSocket.last.subscriptions()).toEqual(['v4_orderbook|BTC-USD', 'v4_trades|ETH-USD', 'v4_markets|']);285    expect(() => a.stream({ type: 'book', symbol: 'bad symbol' }, () => {}, () => {})).toThrow(/invalid market ticker/);286  });287});288
Files main
Menu

Theme