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