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