Repository Public
1// Hyperliquid websocket client with an injected WebSocket: real messages recorded from mainnet / testnet (fixtures *-ws.json),2// simulated disconnects, reconnects, duplicate deliveries, silence and malformed messages. No network; fake timers.3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';4import { BookSchema, FillSchema, PublicTradeSchema, TickerSchema, type Fill, type PublicTrade } from '../../src/index.ts';5import { createHlStream, createHyperliquidAdapter, type HlStreamEvent, type HlStreamStatus, type HlWebSocketLike } from '../../src/venues/hyperliquid/index.ts';6import { fx, raw, replay } from './helpers.ts';78class FakeSocket implements HlWebSocketLike {9 static all: FakeSocket[] = [];10 static failNext = 0;11 onopen: ((ev: unknown) => void) | null = null; onmessage: ((ev: { data: unknown }) => void) | null = null;12 onclose: ((ev: unknown) => void) | null = null; onerror: ((ev: unknown) => void) | null = null;13 sent: any[] = []; closed = false; readonly url: string;14 constructor(url: string) {15 if (FakeSocket.failNext > 0) { FakeSocket.failNext -= 1; throw new Error('connection refused'); }16 this.url = url; FakeSocket.all.push(this);17 }18 send(data: string): void { if (this.closed) throw new Error('socket is closed'); this.sent.push(JSON.parse(data)); }19 close(): void { this.closed = true; }20 // --- test controls ---21 open(): void { this.onopen?.({}); }22 push(msg: unknown): void { this.onmessage?.({ data: typeof msg === 'string' ? msg : JSON.stringify(msg) }); }23 /** the server (or the network) drops the connection */24 drop(): void { this.closed = true; this.onerror?.({}); this.onclose?.({ code: 1006 }); }25 subs(method = 'subscribe'): any[] { return this.sent.filter((m) => m.method === method).map((m) => m.subscription); }26}27const last = () => FakeSocket.all.at(-1)!;2829const ws = raw('mainnet-ws.json') as { subscriptions: any[]; messages: any[] };30const msg = (channel: string, n = 0) => structuredClone(ws.messages.filter((m) => m.channel === channel)[n]);31const USER = ws.subscriptions.find((s) => s.type === 'userFills').user as string;3233function make(o: Partial<Parameters<typeof createHlStream>[0]> = {}) {34 const stream = createHlStream({ network: 'mainnet', WebSocket: FakeSocket, random: () => 1, ...o });35 const listen = () => { const events: HlStreamEvent[] = [], status: HlStreamStatus[] = []; return { events, status, on: (e: unknown) => { events.push(e as HlStreamEvent); }, onStatus: (s: HlStreamStatus) => { status.push(s); } }; };36 return { stream, listen };37}3839beforeEach(() => { vi.useFakeTimers({ now: 1_794_366_000_000 }); FakeSocket.all = []; FakeSocket.failNext = 0; });40afterEach(() => { vi.useRealTimers(); });4142describe('hyperliquid stream — recorded messages', () => {43 it('connects lazily to the documented URL and subscribes', () => {44 const { stream, listen } = make();45 expect(FakeSocket.all.length).toBe(0);46 const l = listen();47 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);48 expect(last().url).toBe('wss://api.hyperliquid.xyz/ws');49 expect(l.status).toEqual([]);50 last().open();51 expect(l.status).toEqual(['connected']);52 expect(last().sent).toEqual([{ method: 'subscribe', subscription: { type: 'l2Book', coin: 'BTC' } }]);53 expect(createHlStream({ network: 'testnet', WebSocket: FakeSocket }).stats().status).toBe('idle');54 });5556 it('book, trades, ticker and mids from real messages', () => {57 const { stream, listen } = make();58 const book = listen(), trades = listen(), ticker = listen(), mids = listen();59 stream.subscribe({ type: 'book', symbol: 'BTC' }, book.on, book.onStatus);60 stream.subscribe({ type: 'trades', symbol: 'BTC' }, trades.on, trades.onStatus);61 stream.subscribe({ type: 'ticker', symbol: 'BTC' }, ticker.on, ticker.onStatus);62 stream.subscribe({ type: 'mids' }, mids.on, mids.onStatus);63 const s = last();64 s.open();65 expect(s.subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'trades', coin: 'BTC' }, { type: 'activeAssetCtx', coin: 'BTC' }, { type: 'allMids' }]);66 for (const m of ws.messages) s.push(m); // the whole recorded session, acks, pong and error messages included6768 const b = book.events.filter((e) => e.type === 'book');69 expect(b.length).toBe(ws.messages.filter((m) => m.channel === 'l2Book').length);70 const rawBook = msg('l2Book').data, got = (b[0] as { book: unknown }).book as ReturnType<typeof BookSchema.parse>;71 BookSchema.parse(got);72 expect(got.ts).toBe(rawBook.time);73 expect(got.bids.length).toBe(rawBook.levels[0].length);74 expect(Number(got.bids[0]![0])).toBe(Number(rawBook.levels[0][0].px)); // display comparison only7576 const t = trades.events.filter((e) => e.type === 'trades').flatMap((e) => (e as { trades: PublicTrade[] }).trades);77 const rawTrades = ws.messages.filter((m) => m.channel === 'trades').flatMap((m) => m.data);78 expect(t.length).toBe(new Set(rawTrades.map((x: any) => `${x.time}-${x.tid}`)).size);79 for (const x of t) PublicTradeSchema.parse(x);8081 const k = ticker.events.filter((e) => e.type === 'ticker');82 expect(k.length).toBe(ws.messages.filter((m) => m.channel === 'activeAssetCtx').length);83 TickerSchema.parse((k[0] as { ticker: unknown }).ticker);84 expect((k[0] as any).ticker.instrument).toBe('hyperliquid:BTC');85 expect((k[0] as any).ticker.ts).toBe(1_794_366_000_000);8687 const m = mids.events.filter((e) => e.type === 'mids');88 expect(m.length).toBe(ws.messages.filter((x) => x.channel === 'allMids').length);89 expect(typeof (m[0] as any).mids.BTC === 'string' || Object.keys((m[0] as any).mids).length > 0).toBe(true);9091 // each consumer only sees its own feed; the venue's `error` messages are reported to everyone as invalid92 expect(book.events.every((e) => e.type === 'book' || e.type === 'invalid')).toBe(true);93 expect(book.events.filter((e) => e.type === 'invalid').length).toBe(ws.messages.filter((x) => x.channel === 'error').length);94 expect(stream.stats()).toMatchObject({ connects: 1, messages: ws.messages.length, subscriptions: 4, status: 'connected' });95 });9697 it('account feed: fills snapshot from a real message, then live fills, order updates, funding, venue cancels', () => {98 const { stream, listen } = make();99 const l = listen();100 stream.subscribe({ type: 'account', account: USER }, l.on, l.onStatus);101 const s = last();102 s.open();103 expect(s.subs()).toEqual([{ type: 'userFills', user: USER }, { type: 'orderUpdates', user: USER }, { type: 'userEvents', user: USER }]);104 const snap = msg('userFills');105 expect(snap.data.isSnapshot).toBe(true);106 s.push(snap);107 const first = l.events[0] as { type: 'fills'; fills: Fill[]; snapshot: boolean };108 expect(first.type).toBe('fills');109 expect(first.snapshot).toBe(true);110 expect(first.fills.length).toBe(snap.data.fills.length);111 for (const f of first.fills) FillSchema.parse(f);112113 // shapes below follow the documented WsOrder / WsUserEvent types (no such message occurred while recording)114 const fill = { ...snap.data.fills[0], tid: 987654321, hash: `0x${'ab'.repeat(32)}`, time: 1_794_366_000_500 };115 s.push({ channel: 'userFills', data: { user: USER, fills: [fill] } });116 s.push({ channel: 'user', data: { fills: [fill] } }); // the same fill again through userEvents117 s.push({ channel: 'orderUpdates', data: [118 { order: { coin: 'BTC', side: 'B', limitPx: '73000.0', sz: '0.001', oid: 91, timestamp: 1_794_366_000_100, origSz: '0.001', cloid: `0x${'01'.repeat(16)}` }, status: 'open', statusTimestamp: 1_794_366_000_100 },119 { order: { coin: 'BTC', side: 'B', limitPx: '73000.0', sz: '0.0004', oid: 91, timestamp: 1_794_366_000_100, origSz: '0.001' }, status: 'open', statusTimestamp: 1_794_366_000_200 },120 { order: { coin: 'BTC', side: 'A', limitPx: '75000.0', sz: '0.0', oid: 92, timestamp: 1_794_366_000_100, origSz: '0.002' }, status: 'filled', statusTimestamp: 1_794_366_000_300 },121 { order: { coin: 'ETH', side: 'A', limitPx: '2000.0', sz: '1.0', oid: 93, timestamp: 1_794_366_000_100, origSz: '1.0' }, status: 'reduceOnlyCanceled', statusTimestamp: 1_794_366_000_400 },122 ] });123 s.push({ channel: 'user', data: { funding: { time: 1_794_366_000_000, coin: 'BTC', usdc: '-0.0123', szi: '0.3', fundingRate: '0.0000125' } } });124 s.push({ channel: 'user', data: { nonUserCancel: [{ coin: 'ETH', oid: 93 }] } });125 s.push({ channel: 'user', data: { liquidation: { lid: 1, liquidator: '0x1', liquidated_user: USER, liquidated_ntl_pos: '1.0', liquidated_account_value: '0.5' } } });126127 const live = l.events.filter((e) => e.type === 'fills' && !e.snapshot) as { fills: Fill[] }[];128 expect(live.length).toBe(1); // delivered once, although it came on two channels129 expect(live[0]!.fills[0]).toMatchObject({ id: `0x${'ab'.repeat(32)}-987654321`, ts: 1_794_366_000_500 });130 const orders = (l.events.find((e) => e.type === 'orders') as { orders: any[] }).orders;131 expect(orders.map((o) => [o.venueOrderId, o.status, o.remaining])).toEqual([['91', 'open', '0.001'], ['91', 'partially_filled', '0.0004'], ['92', 'filled', '0'], ['93', 'cancelled', '1']]);132 expect(orders[0]).toMatchObject({ instrument: 'hyperliquid:BTC', side: 'buy', price: '73000', size: '0.001', clientId: `0x${'01'.repeat(16)}`, venueStatus: 'open' });133 expect(orders[3].venueStatus).toBe('reduceOnlyCanceled');134 expect(l.events.find((e) => e.type === 'funding')).toEqual({ type: 'funding', payment: { instrument: 'hyperliquid:BTC', amount: '-0.0123', rate: '0.0000125', size: '0.3', ts: 1_794_366_000_000 } });135 expect(l.events.find((e) => e.type === 'cancelled_by_venue')).toEqual({ type: 'cancelled_by_venue', orders: [{ instrument: 'hyperliquid:ETH', venueOrderId: '93' }] });136 expect(l.events.some((e) => e.type === 'liquidation')).toBe(true);137 expect(stream.stats().duplicates).toBe(1);138 });139140 it('one account per connection; a malformed address is refused', () => {141 const { stream, listen } = make();142 const l = listen();143 stream.subscribe({ type: 'account', account: USER }, l.on, l.onStatus);144 expect(() => stream.subscribe({ type: 'account', account: `0x${'1'.repeat(40)}` }, l.on, l.onStatus)).toThrow(/one account per stream/);145 expect(() => stream.subscribe({ type: 'account', account: 'nope' }, l.on, l.onStatus)).toThrow(/20-byte hex address/);146 expect(() => stream.subscribe({ type: 'book', symbol: '' }, l.on, l.onStatus)).toThrow(/bad symbol/);147 });148});149150describe('hyperliquid stream — heartbeat, silence, disconnects', () => {151 it('pings on the heartbeat period ({ "method": "ping" }) and stays connected while pongs arrive', () => {152 const { stream, listen } = make();153 const l = listen();154 stream.subscribe({ type: 'ticker', symbol: 'BTC' }, l.on, l.onStatus);155 const s = last();156 s.open();157 for (let i = 0; i < 8; i++) { vi.advanceTimersByTime(15_000); s.push({ channel: 'pong' }); }158 expect(s.sent.filter((m) => m.method === 'ping')).toEqual(Array.from({ length: 8 }, () => ({ method: 'ping' })));159 expect(l.status).toEqual(['connected']);160 expect(FakeSocket.all.length).toBe(1);161 });162163 it('silence → stale; traffic again on the same connection → connected, with no gap', () => {164 const { stream, listen } = make();165 const l = listen();166 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);167 const s = last();168 s.open();169 vi.advanceTimersByTime(26_000);170 expect(l.status).toEqual(['connected', 'stale']);171 s.push(msg('l2Book'));172 expect(l.status).toEqual(['connected', 'stale', 'connected']);173 expect(l.events.some((e) => e.type === 'gap')).toBe(false);174 expect(FakeSocket.all.length).toBe(1);175 });176177 it('prolonged silence → the connection is dropped and re-opened: reconnecting, connected, gap', () => {178 const { stream, listen } = make();179 const l = listen();180 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);181 const first = last();182 first.open();183 vi.advanceTimersByTime(36_500);184 expect(l.status).toEqual(['connected', 'stale', 'reconnecting']);185 expect(first.closed).toBe(true);186 vi.advanceTimersByTime(1000); // first backoff step (random = 1 → the full delay)187 expect(FakeSocket.all.length).toBe(2);188 last().open();189 expect(l.status).toEqual(['connected', 'stale', 'reconnecting', 'connected']);190 expect(l.events.at(-1)).toEqual({ type: 'gap', reason: 'reconnected' });191 expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }]);192 first.push(msg('l2Book')); // a late message from the abandoned socket is ignored193 expect(l.events.filter((e) => e.type === 'book').length).toBe(0);194 });195196 it('server-side disconnect: resubscribes everything and tells every consumer that a gap may exist', () => {197 const { stream, listen } = make();198 const a = listen(), b = listen(), c = listen();199 stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus);200 stream.subscribe({ type: 'trades', symbol: 'ETH' }, b.on, b.onStatus);201 stream.subscribe({ type: 'account', account: USER }, c.on, c.onStatus);202 last().open();203 last().push(msg('l2Book'));204 last().drop();205 expect([a.status, b.status, c.status]).toEqual([['connected', 'reconnecting'], ['connected', 'reconnecting'], ['connected', 'reconnecting']]);206 expect(FakeSocket.all.length).toBe(1);207 vi.advanceTimersByTime(999);208 expect(FakeSocket.all.length).toBe(1);209 vi.advanceTimersByTime(1);210 expect(FakeSocket.all.length).toBe(2);211 expect(a.events.some((e) => e.type === 'gap')).toBe(false); // not yet: nothing is connected212 last().open();213 expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'trades', coin: 'ETH' }, { type: 'userFills', user: USER }, { type: 'orderUpdates', user: USER }, { type: 'userEvents', user: USER }]);214 for (const l of [a, b, c]) { expect(l.status).toEqual(['connected', 'reconnecting', 'connected']); expect(l.events.filter((e) => e.type === 'gap')).toEqual([{ type: 'gap', reason: 'reconnected' }]); }215 last().push(msg('l2Book', 1));216 expect(a.events.map((e) => e.type)).toEqual(['book', 'gap', 'book']);217 });218219 it('backoff doubles up to the cap while the venue stays unreachable, and starts again after a connection that lasted', () => {220 const { stream, listen } = make({ backoffMinMs: 1000, backoffMaxMs: 8000 });221 const l = listen();222 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);223 last().open();224 last().drop();225 const delays: number[] = [];226 for (let i = 0; i < 6; i++) {227 const n = FakeSocket.all.length;228 let waited = 0;229 while (FakeSocket.all.length === n && waited < 60_000) { vi.advanceTimersByTime(250); waited += 250; }230 delays.push(waited);231 last().drop(); // accepted, then dropped at once (what the server does on a bad subscription)232 }233 expect(delays).toEqual([1000, 2000, 4000, 8000, 8000, 8000]);234 expect(l.status).toEqual(['connected', 'reconnecting']);235 // constructor failures count too236 FakeSocket.failNext = 2;237 vi.advanceTimersByTime(30_000);238 expect(last().closed).toBe(false);239 last().open();240 expect(l.status.at(-1)).toBe('connected');241 vi.advanceTimersByTime(10_000); last().push({ channel: 'pong' }); vi.advanceTimersByTime(1000);242 last().drop();243 const n = FakeSocket.all.length;244 vi.advanceTimersByTime(1000);245 expect(FakeSocket.all.length).toBe(n + 1); // back to the minimum delay246 });247248 it('jitter keeps the delay between half and the whole step', () => {249 const { stream, listen } = make({ random: () => 0 });250 const l = listen();251 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);252 last().open(); last().drop();253 vi.advanceTimersByTime(499);254 expect(FakeSocket.all.length).toBe(1);255 vi.advanceTimersByTime(1);256 expect(FakeSocket.all.length).toBe(2);257 });258259 it('duplicate deliveries: a fill or a trade is emitted once, also across a reconnect snapshot', () => {260 const { stream, listen } = make();261 const acct = listen(), trades = listen();262 stream.subscribe({ type: 'account', account: USER }, acct.on, acct.onStatus);263 stream.subscribe({ type: 'trades', symbol: 'BTC' }, trades.on, trades.onStatus);264 last().open();265 const snap = msg('userFills'), tr = msg('trades');266 last().push(snap); last().push(tr); last().push(tr); last().push(snap);267 const count = (l: ReturnType<typeof listen>, type: string, key: string) => l.events.filter((e) => e.type === type).reduce((n, e) => n + (e as any)[key].length, 0);268 expect(count(acct, 'fills', 'fills')).toBe(snap.data.fills.length);269 expect(count(trades, 'trades', 'trades')).toBe(new Set(tr.data.map((x: any) => `${x.time}-${x.tid}`)).size);270271 // reconnect: the snapshot comes again, with one fill that happened during the gap — only that one is emitted272 last().drop(); vi.advanceTimersByTime(1000); last().open();273 const missed = { ...snap.data.fills[0], tid: 555, hash: `0x${'cd'.repeat(32)}`, time: snap.data.fills[0].time + 1 };274 const again = structuredClone(snap);275 again.data.fills.unshift(missed);276 const before = acct.events.length;277 last().push(again);278 const after = acct.events.slice(before).filter((e) => e.type === 'fills') as { fills: Fill[]; snapshot: boolean }[];279 expect(after.length).toBe(1);280 expect(after[0]!.snapshot).toBe(true);281 expect(after[0]!.fills.map((f) => f.id)).toEqual([`0x${'cd'.repeat(32)}-555`]);282 last().push(tr);283 expect(count(trades, 'trades', 'trades')).toBe(new Set(tr.data.map((x: any) => `${x.time}-${x.tid}`)).size);284 expect(stream.stats().duplicates).toBeGreaterThan(0);285 });286287 it('malformed messages are dropped and reported, the stream goes on', () => {288 const { stream, listen } = make();289 const l = listen();290 stream.subscribe({ type: 'book', symbol: 'BTC' }, l.on, l.onStatus);291 const s = last();292 s.open();293 const crossed = msg('l2Book'); crossed.data.levels[0][0].px = '999999999';294 const numeric = msg('l2Book'); numeric.data.levels[1][0].px = 73700.5;295 for (const bad of ['not json', '{"no":"channel"}', { channel: 'l2Book', data: { coin: 'BTC' } }, crossed, numeric, { channel: 'l2Book' }, { channel: 'error', data: 'Already subscribed' }, 42, null]) s.push(bad as never);296 expect(l.events.every((e) => e.type === 'invalid')).toBe(true);297 expect(l.events.length).toBe(9);298 expect(stream.stats().invalid).toBe(9);299 s.push({ channel: 'somethingNew', data: {} }); // unknown channels and feeds we did not ask for are ignored silently300 s.push({ channel: 'l2Book', data: { ...msg('l2Book').data, coin: 'ETH' } });301 expect(l.events.length).toBe(9);302 s.push(msg('l2Book'));303 expect(l.events.at(-1)!.type).toBe('book');304 expect(l.status).toEqual(['connected']);305 });306307 it('a consumer that throws does not break the others', () => {308 const { stream, listen } = make();309 const good = listen();310 stream.subscribe({ type: 'book', symbol: 'BTC' }, () => { throw new Error('consumer bug'); }, () => { throw new Error('consumer bug'); });311 stream.subscribe({ type: 'book', symbol: 'BTC' }, good.on, good.onStatus);312 last().open(); last().push(msg('l2Book'));313 expect(good.events.map((e) => e.type)).toEqual(['book']);314 expect(last().subs().length).toBe(1); // one wire subscription for two consumers315 });316317 it('unsubscribe: the feed is released with the last consumer, the socket with the last feed', () => {318 const { stream, listen } = make();319 const a = listen(), b = listen(), c = listen();320 const ua = stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus), ub = stream.subscribe({ type: 'book', symbol: 'BTC' }, b.on, b.onStatus);321 const uc = stream.subscribe({ type: 'trades', symbol: 'BTC' }, c.on, c.onStatus);322 const s = last();323 s.open();324 ua(); ua();325 expect(s.subs('unsubscribe')).toEqual([]);326 s.push(msg('l2Book'));327 expect([a.events.length, b.events.length]).toEqual([0, 1]);328 ub();329 expect(s.subs('unsubscribe')).toEqual([{ type: 'l2Book', coin: 'BTC' }]);330 expect(s.closed).toBe(false);331 uc();332 expect(s.closed).toBe(true);333 expect(stream.stats().status).toBe('idle');334 vi.advanceTimersByTime(120_000);335 expect(FakeSocket.all.length).toBe(1); // no timer left, no reconnect336 // a new subscription starts a fresh connection, and it is a first connection again (no gap event)337 const d = listen();338 stream.subscribe({ type: 'book', symbol: 'BTC' }, d.on, d.onStatus);339 last().open();340 expect(d.events).toEqual([]);341 expect(d.status).toEqual(['connected']);342 stream.close();343 expect(last().closed).toBe(true);344 });345346 it('a subscription made while reconnecting learns the state and is included in the next connection', () => {347 const { stream, listen } = make();348 const a = listen(), b = listen();349 stream.subscribe({ type: 'book', symbol: 'BTC' }, a.on, a.onStatus);350 last().open(); last().drop();351 stream.subscribe({ type: 'ticker', symbol: 'ETH' }, b.on, b.onStatus);352 expect(b.status).toEqual(['reconnecting']);353 vi.advanceTimersByTime(1000); last().open();354 expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }, { type: 'activeAssetCtx', coin: 'ETH' }]);355 expect(b.status).toEqual(['reconnecting', 'connected']);356 });357});358359describe('hyperliquid adapter.stream', () => {360 it('checks the symbol against the venue table before subscribing (an unknown coin makes the server drop the connection)', async () => {361 vi.useRealTimers();362 FakeSocket.all = [];363 const r = replay({ meta: { body: fx('mainnet-meta').body } });364 const adapter = createHyperliquidAdapter({ network: 'mainnet', fetch: r.fetchImpl, stream: { WebSocket: FakeSocket } });365 const events: any[] = [], status: string[] = [];366 adapter.stream({ type: 'book', symbol: 'NOT-A-COIN' }, (e) => events.push(e), (s) => status.push(s));367 const stop = adapter.stream({ type: 'book', symbol: 'BTC' }, (e) => events.push(e), (s) => status.push(s));368 await new Promise((res) => setTimeout(res, 20));369 expect(events).toEqual([{ type: 'invalid', channel: 'subscribe', reason: "unknown instrument 'NOT-A-COIN'" }]);370 expect(FakeSocket.all.length).toBe(1);371 last().open();372 expect(last().subs()).toEqual([{ type: 'l2Book', coin: 'BTC' }]);373 last().push(msg('l2Book'));374 expect(events.at(-1).type).toBe('book');375 expect(status).toEqual(['connected']);376 stop();377 expect(last().closed).toBe(true);378 // cancelled before the table arrived: never subscribes379 const cancel = adapter.stream({ type: 'trades', symbol: 'ETH' }, () => {}, () => {});380 cancel();381 await new Promise((res) => setTimeout(res, 20));382 expect(FakeSocket.all.length).toBe(1);383 adapter.close();384 });385});386