SPB Git

Repository Public

adapter.test.ts 19.2 KB 267 lines typescript

1// dYdX v4 read adapter against real indexer answers recorded on 2026-11-10/11 (test/fixtures/record.mjs).2// Market data: mainnet. Account reads: public testnet accounts (mainnet account endpoints answered 403 GEOBLOCKED from the3// recording location; that answer is a fixture too).4import { describe, expect, it } from 'vitest';5import { accountState, candle, fill, fundingPayment, instrument, order, position, publicTrade, ticker, book as bookSchema } from '../venue-helpers.ts';6import { canonDec, createDydxAdapter, D, VenueError,7  priceToSubticks, quantumsToSize, quoteQuantumsToUsdc, sizeToQuantums, stepSizeOf, subticksToPrice, tickSizeOf, DYDX_CAPABILITIES } from '../../src/index.ts';8import { fx, replay, type Route } from '../venue-helpers.ts';9import { ds } from '../../src/venues/http.ts';1011const MAIN: Route[] = [12  [/\/v4\/perpetualMarkets$/, fx('dydx', 'perpetualMarkets')], ['/orderbooks/perpetualMarket/BTC-USD', fx('dydx', 'orderbook-BTC-USD')],13  ['/orderbooks/perpetualMarket/NOPE-USD', fx('dydx', 'orderbook-unknown')], ['/trades/perpetualMarket/', fx('dydx', 'trades-BTC-USD')],14  ['/candles/perpetualMarkets/', fx('dydx', 'candles-BTC-USD-1HOUR')], ['/historicalFunding/', fx('dydx', 'historicalFunding-BTC-USD')],15  ['/height', fx('dydx', 'height')], ['/addresses/', fx('dydx', 'mainnet-account-geoblocked')],16];17const TEST: Route[] = [18  [/\/v4\/perpetualMarkets$/, fx('dydx', 'testnet-perpetualMarkets')], ['dydx1qqqq', fx('dydx', 'testnet-subaccount-unknown')], ['/subaccountNumber/', fx('dydx', 'testnet-subaccount')],19  ['status=OPEN', fx('dydx', 'testnet-orders-open')], ['/orders', fx('dydx', 'testnet-orders-history')], ['/fills', fx('dydx', 'testnet-fills')],20  ['/fundingPayments', fx('dydx', 'testnet-fundingPayments')], [/\/addresses\/dydx1\w+$/, fx('dydx', 'testnet-address')],21];22const NOW = Date.parse('2026-11-11T03:00:00Z');23const mainnet = (patch?: (url: string, body: any) => unknown) => { const r = replay(MAIN, patch); return { a: createDydxAdapter({ fetch: r.fetch, now: () => NOW }), calls: r.calls }; };24const testnet = (patch?: (url: string, body: any) => unknown) => { const r = replay(TEST, patch); return { a: createDydxAdapter({ network: 'testnet', fetch: r.fetch, now: () => NOW }), calls: r.calls }; };25const ACCOUNT = 'dydx1zzz66hkkjy6apat9w2enw37484v3xhqnlducf7';26const rejects = async (p: Promise<unknown>, code: string, re?: RegExp) => {27  const e = await p.then(() => null, (x) => x);28  expect(e).toBeInstanceOf(VenueError);29  expect((e as VenueError).code).toBe(code);30  if (re) expect((e as VenueError).message).toMatch(re);31};32const rawMarkets = JSON.parse(fx('dydx', 'perpetualMarkets').text).markets as Record<string, any>;3334describe('dYdX units: quantums and subticks (bigint only)', () => {35  const BTC = { atomicResolution: -10, quantumConversionExponent: -9, stepBaseQuantums: 1_000_000, subticksPerTick: 100_000 };36  it('converts BTC-USD sizes and prices exactly, both ways', () => {37    expect(BTC).toEqual({ atomicResolution: rawMarkets['BTC-USD'].atomicResolution, quantumConversionExponent: rawMarkets['BTC-USD'].quantumConversionExponent,38      stepBaseQuantums: rawMarkets['BTC-USD'].stepBaseQuantums, subticksPerTick: rawMarkets['BTC-USD'].subticksPerTick });39    expect(sizeToQuantums('0.0001', BTC)).toBe(1_000_000n);40    expect(sizeToQuantums('1.2345', BTC)).toBe(12_345_000_000n);41    expect(quantumsToSize(12_345_000_000n, BTC)).toBe('1.2345');42    expect(priceToSubticks('73650', BTC)).toBe(7_365_000_000n);43    expect(subticksToPrice(7_365_000_000n, BTC)).toBe('73650');44    expect(tickSizeOf(BTC)).toBe('1');45    expect(stepSizeOf(BTC)).toBe('0.0001');46    expect(quoteQuantumsToUsdc(2_639_091_750n)).toBe('2639.09175');47  });48  it('stays exact beyond 2^53 (a JS number would round these)', () => {49    const q = 9_007_199_254_740_993_000_000n;                    // 2^53 + 1, times the step50    expect(Number(q) === Number(q + 1n)).toBe(true);             // proof that a double cannot hold it51    expect(quantumsToSize(q, BTC)).toBe('900719925474.0993');52    expect(sizeToQuantums('900719925474.0993', BTC)).toBe(q);53    expect(subticksToPrice(123_456_789_012_345_678_900_000n, BTC)).toBe('1234567890123456789');54    expect(priceToSubticks('1234567890123456789', BTC)).toBe(123_456_789_012_345_678_900_000n);55  });56  it('refuses values that are not on the lot or the tick, instead of rounding', () => {57    expect(() => sizeToQuantums('0.00015', BTC)).toThrow(/multiple of the step/);58    expect(() => sizeToQuantums('0.00000000001', BTC)).toThrow();59    expect(() => priceToSubticks('73650.5', BTC)).toThrow(/multiple of the tick/);60    expect(() => priceToSubticks('1e5', BTC)).toThrow();61  });62  it('the protocol parameters reproduce the indexer tick and step of every market (296 markets, positive and negative exponents)', () => {63    const all = Object.values(rawMarkets);64    expect(all.length).toBe(296);65    for (const m of all) {66      expect(D.cmp(D.from(tickSizeOf(m)), D.from(m.tickSize)), m.ticker).toBe(0);67      expect(D.cmp(D.from(stepSizeOf(m)), D.from(m.stepSize)), m.ticker).toBe(0);68      // round trip of one tick above the oracle price, quantized down69      const p = ds(D.quantize(D.from(m.oraclePrice), D.from(tickSizeOf(m)), 'down'));70      if (p !== '0') expect(subticksToPrice(priceToSubticks(p, m), m)).toBe(p);71    }72  });73});7475describe('dYdX market data (mainnet fixtures)', () => {76  it('lists every perpetual with all Instrument fields from the venue', async () => {77    const { a } = mainnet();78    const list = await a.instruments();79    expect(list.length).toBe(296);80    for (const i of list) expect(instrument.safeParse(i).success, i.symbol).toBe(true);81    expect(list.filter((i) => i.status === 'active').length).toBe(57);82    const btc = list.find((i) => i.symbol === 'BTC-USD')!;83    expect(btc).toMatchObject({ venue: 'dydx', id: 'dydx:BTC-USD', base: 'BTC', quote: 'USD', kind: 'perpetual', collateral: 'USDC', tickSize: '1', lotSize: '0.0001',84      minOrderSize: '0.0001', minNotional: null, maxLeverage: '50', marginModes: ['cross', 'isolated'], fundingIntervalSec: 3600, status: 'active' });85    expect(list.find((i) => i.symbol === 'ETH-USD')).toMatchObject({ tickSize: '0.1', lotSize: '0.001', maxLeverage: '50' });86    const iso = list.find((i) => rawMarkets[i.symbol].marketType === 'ISOLATED')!;87    expect(iso.marginModes).toEqual(['isolated']);88    expect(list.find((i) => rawMarkets[i.symbol].status === 'FINAL_SETTLEMENT')!.status).toBe('halted');89  });90  it('tickers: oracle price as mark and index, hourly funding, open interest, 24 h volume — strings straight from the venue', async () => {91    const { a } = mainnet();92    const t = (await a.tickers()).find((x) => x.instrument === 'dydx:BTC-USD')!;93    expect(ticker.safeParse(t).success).toBe(true);94    const raw = rawMarkets['BTC-USD'];95    expect(t).toMatchObject({ mark: raw.oraclePrice, index: raw.oraclePrice, mid: null, funding: canonDec(raw.nextFundingRate), openInterest: canonDec(raw.openInterest), volume24h: canonDec(raw.volume24H), ts: NOW });96    // change is a fraction of the price 24 h earlier, computed in decimals: change / (oracle − change)97    const prev = D.sub(D.from(raw.oraclePrice), D.from(raw.priceChange24H));98    expect(t.change24h).toBe(ds(D.div(D.from(raw.priceChange24H), prev, 8, 'half_even')));99    expect(Number(t.change24h)).toBeLessThan(0);100  });101  it('order book: real levels, best first on both sides, depth honoured', async () => {102    const { a } = mainnet();103    const b = await a.book('BTC-USD', 5);104    expect(bookSchema.safeParse(b).success).toBe(true);105    expect(b.bids.length).toBe(5); expect(b.asks.length).toBe(5);106    for (let i = 1; i < 5; i++) {107      expect(D.cmp(D.from(b.bids[i - 1]![0]), D.from(b.bids[i]![0]))).toBe(1);108      expect(D.cmp(D.from(b.asks[i - 1]![0]), D.from(b.asks[i]![0]))).toBe(-1);109    }110    expect(D.cmp(D.from(b.bids[0]![0]), D.from(b.asks[0]![0]))).toBe(-1);111    const raw = JSON.parse(fx('dydx', 'orderbook-BTC-USD').text);112    expect(b.bids[0]).toEqual([raw.bids[0].price, raw.bids[0].size]);113    expect((await a.book('BTC-USD')).bids.length).toBe(50);114  });115  it('an unknown market is the venue refusing, not a broken answer', async () => {116    await rejects(mainnet().a.book('NOPE-USD'), 'rejected', /NOPE-USD not found/);117    await rejects(mainnet().a.book('BTC USD/../x'), 'rejected', /invalid market ticker/);118  });119  it('trades, candles (oldest first, base volume) and applied funding history', async () => {120    const { a, calls } = mainnet();121    const tr = await a.trades('BTC-USD', 50);122    expect(tr.length).toBe(50);123    for (const t of tr) expect(publicTrade.safeParse(t).success).toBe(true);124    expect(new Set(tr.map((t) => t.id)).size).toBe(50);125    const cs = await a.candles('BTC-USD', '1h', { limit: 24 });126    expect(cs.length).toBe(24);127    for (const c of cs) expect(candle.safeParse(c).success).toBe(true);128    for (let i = 1; i < cs.length; i++) expect(cs[i]!.ts - cs[i - 1]!.ts).toBe(3_600_000);129    expect(calls.find((c) => c.url.includes('/candles/'))!.url).toMatch(/resolution=1HOUR&limit=24$/);130    const all = await a.fundingHistory('BTC-USD', { start: 0 });131    expect(all.length).toBe(48);132    for (let i = 1; i < all.length; i++) expect(all[i]!.ts).toBeGreaterThan(all[i - 1]!.ts);133    expect(all.every((x) => x.premium === null && /^-?\d+(\.\d+)?$/.test(x.rate))).toBe(true);134    const cut = all[40]!.ts;135    expect((await a.fundingHistory('BTC-USD', { start: cut })).length).toBe(8);136    expect((await a.fundingHistory('BTC-USD', { start: all[10]!.ts, end: all[20]!.ts })).length).toBe(11);137  });138  it('reports how far the indexer has processed', async () => {139    const h = await mainnet().a.height();140    expect(h.height).toMatch(/^\d+$/);141    expect(Math.abs(h.ts - Date.parse(fx('dydx', 'height').recordedAt))).toBeLessThan(60_000);142  });143  it('capabilities are honest: read-only, real stream, no trading', () => {144    const { a } = mainnet();145    expect(a.capabilities).toBe(DYDX_CAPABILITIES);146    expect(a.capabilities).toMatchObject({ marketData: true, account: true, orders: false, orderTypes: [], tif: [], modify: false, stream: true, testnet: true, subaccounts: true });147    expect(a.trading).toBeUndefined();148    expect(a.capabilities.notes.join(' ')).toMatch(/SIGN_DIRECT/);149    expect(a.capabilities.notes.join(' ')).toMatch(/GEOBLOCKED/);150  });151});152153describe('dYdX account reads (testnet fixtures)', () => {154  it('account state: equity and free collateral as reported, margin used by exact subtraction', async () => {155    const { a, calls } = testnet();156    const s = await a.account(ACCOUNT);157    expect(accountState.safeParse(s).success).toBe(true);158    const raw = JSON.parse(fx('dydx', 'testnet-subaccount').text).subaccount;159    expect(s).toMatchObject({ venue: 'dydx', account: `${ACCOUNT}/0`, equity: raw.equity, freeCollateral: raw.freeCollateral, collateral: raw.assetPositions.USDC.size,160      maintenanceMargin: null, withdrawable: null, source: 'venue' });161    expect(D.cmp(D.add(D.from(s.marginUsed), D.from(s.freeCollateral)), D.from(s.equity))).toBe(0);162    expect(calls[0]!.url).toMatch(new RegExp(`/addresses/${ACCOUNT}/subaccountNumber/0$`));163    await a.account(`${ACCOUNT}/128`);164    expect(calls[1]!.url).toMatch(/subaccountNumber\/128$/);165  });166  it('positions: venue fields kept, derived fields computed in decimals', async () => {167    const ps = await testnet().a.positions(ACCOUNT);168    expect(ps.map((p) => p.instrument).sort()).toEqual(['dydx:ETH-USD', 'dydx:LINK-USD', 'dydx:TRX-USD']);169    for (const p of ps) expect(position.safeParse(p).success).toBe(true);170    const trx = ps.find((p) => p.instrument === 'dydx:TRX-USD')!;171    const oracle = JSON.parse(fx('dydx', 'testnet-perpetualMarkets').text).markets['TRX-USD'].oraclePrice;172    expect(trx).toMatchObject({ side: 'short', size: '100', entryPrice: '0.10285', markPrice: oracle, liquidationPrice: null, marginMode: 'cross', realizedPnl: '0', fundingPaid: '-4.508714', source: 'venue' });173    expect(trx.dydx.notional).toBe(ds(D.mul(D.from('100'), D.from(oracle))));174    const eq = JSON.parse(fx('dydx', 'testnet-subaccount').text).subaccount.equity;175    expect(trx.leverage).toBe(ds(D.div(D.from(trx.dydx.notional), D.from(eq), 4, 'half_even')));176    expect(ps.find((p) => p.instrument === 'dydx:LINK-USD')!.fundingPaid).toBe('0.405107');177  });178  it('orders: history and open orders mapped without losing the venue status', async () => {179    const { a, calls } = testnet();180    const hist = await a.orderHistory(ACCOUNT, { limit: 20 });181    for (const o of hist) expect(order.safeParse(o).success).toBe(true);182    const stop = hist.find((o) => o.dydx.type === 'STOP_LIMIT')!;183    expect(stop).toMatchObject({ instrument: 'dydx:BTC-USD', type: 'stop_limit', side: 'sell', status: 'cancelled', tif: 'ioc', triggerPrice: '3', price: '2', filled: '0', clientId: '309878392', createdAt: 0 });184    expect(stop.dydx).toMatchObject({ status: 'CANCELED', orderFlags: '32', timeInForce: 'IOC' });185    expect(hist.filter((o) => o.status === 'filled').every((o) => o.filled === o.size)).toBe(true);186    const open = await a.openOrders(ACCOUNT);187    expect(open.length).toBe(1);188    expect(open[0]).toMatchObject({ instrument: 'dydx:TAO-USD', type: 'limit', tif: 'gtc', reduceOnly: false, rejectReason: null });189    expect(['open', 'partially_filled']).toContain(open[0]!.status);190    expect(open[0]!.dydx.orderFlags).toBe('64');                 // long-term (stateful) order191    expect(calls.at(-1)!.url).toContain('status=OPEN%2CUNTRIGGERED%2CBEST_EFFORT_OPENED');192  });193  it('unconfirmed indexer states are never reported as final', async () => {194    const { a } = testnet((url, body) => (url.includes('status=OPEN') ? body.map((o: any) => ({ ...o, status: 'BEST_EFFORT_CANCELED' })) : undefined));195    expect((await a.openOrders(ACCOUNT))[0]!.status).toBe('pending');196    const u = testnet((url, body) => (url.includes('status=OPEN') ? body.map((o: any) => ({ ...o, type: 'TWAP' })) : undefined));197    await rejects(u.a.openOrders(ACCOUNT), 'unsupported', /TWAP/);198  });199  it('fills and funding payments', async () => {200    const { a } = testnet();201    const fs = await a.fills(ACCOUNT);202    for (const f of fs) expect(fill.safeParse(f).success).toBe(true);203    const taker = fs.find((f) => f.instrument === 'dydx:TRX-USD')!;204    expect(taker).toMatchObject({ side: 'sell', price: '0.10285', size: '100', fee: '0.005143', feeAsset: 'USDC', liquidation: false, closedPnl: null });205    expect(taker.dydx.liquidity).toBe('TAKER');206    expect((await a.fills(ACCOUNT, { start: Date.parse('2026-01-01') })).every((f) => f.ts >= Date.parse('2026-01-01'))).toBe(true);207    const fp = await a.funding(ACCOUNT);208    expect(fp.length).toBeGreaterThan(5);209    for (const p of fp) expect(fundingPayment.safeParse(p).success).toBe(true);210    const raw = JSON.parse(fx('dydx', 'testnet-fundingPayments').text).fundingPayments[0];211    expect(fp[0]).toEqual({ instrument: `dydx:${raw.ticker}`, amount: raw.payment, rate: raw.rate, size: raw.size, ts: Date.parse(raw.createdAt) });212  });213  it('lists the subaccounts of an address; refuses malformed account identifiers before any request', async () => {214    const { a, calls } = testnet();215    expect(await a.subaccounts(ACCOUNT)).toEqual([0]);216    const n = calls.length;217    await rejects(a.account('0x46a3BA57e146A4906bc68759Fb7ad1E74d3B66f6'), 'rejected');218    await rejects(a.positions(`${ACCOUNT}/999999`), 'rejected');219    await rejects(a.account('dydx1short'), 'rejected');220    expect(calls.length).toBe(n);221  });222  it('an unknown subaccount and a geoblocked request are refusals with the venue reason', async () => {223    await rejects(testnet().a.account('dydx1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqd4mhgw'), 'rejected', /No subaccount found/);224    await rejects(mainnet().a.account(ACCOUNT), 'rejected', /GEOBLOCKED/);225    await rejects(mainnet().a.positions(ACCOUNT), 'rejected', /GEOBLOCKED/);226  });227});228229describe('dYdX malformed or failing answers', () => {230  const drop = (path: string, mutate: (body: any) => void) => (url: string, body: any) => { if (url.includes(path)) { mutate(body); return body; } return undefined; };231  it('missing or mistyped fields are bad_answer, never a default value', async () => {232    await rejects(mainnet(drop('/perpetualMarkets', (b) => { delete b.markets['BTC-USD'].oraclePrice; })).a.tickers(), 'bad_answer', /oraclePrice/);233    await rejects(mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].tickSize = 1; })).a.instruments(), 'bad_answer', /tickSize/);234    await rejects(mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].oraclePrice = '7.3e4'; })).a.tickers(), 'bad_answer');235    await rejects(mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].status = 'WHATEVER'; })).a.instruments(), 'bad_answer');236    await rejects(mainnet(drop('/orderbooks/', (b) => { b.bids[3].size = null; })).a.book('BTC-USD'), 'bad_answer');237    await rejects(mainnet(drop('/orderbooks/', (b) => { delete b.asks; })).a.book('BTC-USD'), 'bad_answer');238    await rejects(mainnet(drop('/trades/', (b) => { b.trades[0].side = 'UP'; })).a.trades('BTC-USD'), 'bad_answer');239    await rejects(mainnet(drop('/candles/', (b) => { b.candles[0].startedAt = 'yesterday'; })).a.candles('BTC-USD', '1h', {}), 'bad_answer');240    await rejects(mainnet(drop('/historicalFunding/', (b) => { b.historicalFunding[0].rate = 0.0001; })).a.fundingHistory('BTC-USD', { start: 0 }), 'bad_answer');241    await rejects(testnet(drop('/subaccountNumber/', (b) => { delete b.subaccount.equity; })).a.account(ACCOUNT), 'bad_answer', /equity/);242    await rejects(testnet(drop('/fills', (b) => { b.fills[0].fee = 12; })).a.fills(ACCOUNT), 'bad_answer');243    await rejects(testnet(drop('/orders', (b) => { delete b[0].clientId; })).a.orderHistory(ACCOUNT), 'bad_answer');244    await rejects(testnet(drop('/orders', (b) => { b[0].status = 'SOMETHING_NEW'; })).a.orderHistory(ACCOUNT), 'bad_answer');245  });246  it('a tick that disagrees with the protocol parameters is refused', async () => {247    await rejects(mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].tickSize = '0.5'; })).a.instruments(), 'bad_answer', /tickSize 0.5/);248    await rejects(mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].initialMarginFraction = '0'; })).a.instruments(), 'bad_answer');249  });250  it('a position in a market the venue does not list is refused', async () => {251    await rejects(testnet(drop('/perpetualMarkets', (b) => { delete b.markets['TRX-USD']; })).a.positions(ACCOUNT), 'bad_answer', /TRX-USD/);252  });253  it('unknown extra fields are tolerated', async () => {254    const { a } = mainnet(drop('/perpetualMarkets', (b) => { b.markets['BTC-USD'].somethingNew = { x: 1 }; b.extra = true; }));255    expect((await a.instruments()).length).toBe(296);256  });257  it('transport and status errors map to the contract codes', async () => {258    const one = (answer: Route[1]) => createDydxAdapter({ fetch: replay([[/./, answer]]).fetch });259    await rejects(one({ status: 429, text: 'slow down' }).tickers(), 'rate_limited');260    await rejects(one({ status: 503, text: 'upstream' }).tickers(), 'unavailable');261    await rejects(one({ status: 200, text: '<html>maintenance</html>' }).tickers(), 'bad_answer', /non-JSON/);262    await rejects(one({ status: 200, text: 'null' }).tickers(), 'bad_answer');263    await rejects(one(() => { throw new TypeError('fetch failed'); }).tickers(), 'unavailable', /fetch failed/);264    await rejects(one({ status: 400, text: '{"errors":[{"msg":"limit must be a positive integer"}]}' }).trades('BTC-USD'), 'rejected', /limit must be/);265  });266});267
Files main
Menu

Theme