Repository Public
1// dYdX v4 indexer wire formats (zod) and the exact unit conversions of the protocol (quantums, subticks).2// Shapes checked against the live indexer on 2026-11-10 (fixtures in packages/derivatives/test/fixtures/dydx/).3// Reference: https://docs.dydx.xyz/indexer-client/http and https://docs.dydx.xyz/indexer-client/websockets4import { z } from 'zod';5import { canonDec, fixed, toRaw } from '../http.ts';67/** decimal string as the indexer prints it (never an exponent) */8export const dec = z.string().regex(/^-?\d+(\.\d+)?$/);9const uintStr = z.string().regex(/^\d+$/);10const iso = z.string().refine((s) => Number.isFinite(Date.parse(s)), 'ISO date');11export const canon = canonDec;12const cdec = dec.transform(canon);1314export const MARKET_STATUS = ['ACTIVE', 'PAUSED', 'CANCEL_ONLY', 'POST_ONLY', 'INITIALIZING', 'FINAL_SETTLEMENT'] as const;15export const perpetualMarket = z.object({16 clobPairId: uintStr, ticker: z.string().min(1), status: z.enum(MARKET_STATUS),17 oraclePrice: cdec, priceChange24H: cdec, volume24H: cdec, trades24H: z.number().int().nonnegative(),18 nextFundingRate: cdec, initialMarginFraction: cdec, maintenanceMarginFraction: cdec, openInterest: cdec,19 atomicResolution: z.number().int(), quantumConversionExponent: z.number().int(),20 tickSize: cdec, stepSize: cdec, stepBaseQuantums: z.number().int().positive(), subticksPerTick: z.number().int().positive(),21 marketType: z.enum(['CROSS', 'ISOLATED']),22 openInterestLowerCap: cdec.optional(), openInterestUpperCap: cdec.optional(), baseOpenInterest: cdec.optional(), defaultFundingRate1H: cdec.optional(),23});24export type PerpetualMarket = z.infer<typeof perpetualMarket>;25export const perpetualMarkets = z.object({ markets: z.record(z.string(), perpetualMarket) });2627const level = z.object({ price: cdec, size: cdec });28export const orderbook = z.object({ bids: z.array(level), asks: z.array(level) });2930export const trade = z.object({ id: z.string().min(1), side: z.enum(['BUY', 'SELL']), size: cdec, price: cdec, type: z.string(), createdAt: iso, createdAtHeight: uintStr });31export const trades = z.object({ trades: z.array(trade) });3233export const candle = z.object({ startedAt: iso, ticker: z.string(), resolution: z.string(), low: cdec, high: cdec, open: cdec, close: cdec, baseTokenVolume: cdec, usdVolume: cdec, trades: z.number().int() });34export const candles = z.object({ candles: z.array(candle) });3536export const historicalFunding = z.object({ historicalFunding: z.array(z.object({ ticker: z.string(), rate: cdec, price: cdec, effectiveAt: iso, effectiveAtHeight: uintStr })) });3738export const perpetualPosition = z.object({39 market: z.string().min(1), status: z.enum(['OPEN', 'CLOSED', 'LIQUIDATED']), side: z.enum(['LONG', 'SHORT']),40 size: cdec, maxSize: cdec, entryPrice: cdec, exitPrice: cdec.nullable().optional(), realizedPnl: cdec, unrealizedPnl: cdec,41 createdAt: iso, closedAt: iso.nullable().optional(), sumOpen: cdec, sumClose: cdec, netFunding: cdec, subaccountNumber: z.number().int().nonnegative(),42});43export type PerpetualPosition = z.infer<typeof perpetualPosition>;44export const assetPosition = z.object({ size: cdec, symbol: z.string().min(1), side: z.enum(['LONG', 'SHORT']), assetId: z.string(), subaccountNumber: z.number().int().nonnegative() });45export const subaccount = z.object({46 address: z.string(), subaccountNumber: z.number().int().nonnegative(), equity: cdec, freeCollateral: cdec,47 openPerpetualPositions: z.record(z.string(), perpetualPosition), assetPositions: z.record(z.string(), assetPosition),48 marginEnabled: z.boolean(), updatedAtHeight: uintStr.optional(), latestProcessedBlockHeight: uintStr.optional(),49});50export type Subaccount = z.infer<typeof subaccount>;51export const subaccountAnswer = z.object({ subaccount });52export const addressAnswer = z.object({ subaccounts: z.array(subaccount) });53export const positionsAnswer = z.object({ positions: z.array(perpetualPosition) });5455export const ORDER_STATUS = ['OPEN', 'FILLED', 'CANCELED', 'BEST_EFFORT_CANCELED', 'UNTRIGGERED', 'BEST_EFFORT_OPENED', 'PENDING'] as const;56/** REST and websocket snapshot shape. Websocket *updates* carry `totalOptimisticFilled` instead of `totalFilled` and may omit fields. */57export const order = z.object({58 id: z.string().min(1), clientId: uintStr, clobPairId: uintStr, side: z.enum(['BUY', 'SELL']), size: cdec,59 totalFilled: cdec.optional(), totalOptimisticFilled: cdec.optional(), price: cdec, type: z.string().min(1), status: z.string().min(1),60 timeInForce: z.string().min(1), reduceOnly: z.boolean(), postOnly: z.boolean(), orderFlags: uintStr,61 goodTilBlock: uintStr.nullable().optional(), goodTilBlockTime: iso.nullable().optional(), createdAtHeight: uintStr.nullable().optional(),62 triggerPrice: cdec.nullable().optional(), updatedAt: iso.nullable().optional(), updatedAtHeight: uintStr.nullable().optional(),63 ticker: z.string().min(1), subaccountNumber: z.number().int().nonnegative().optional(), removalReason: z.string().optional(), clientMetadata: z.string().nullable().optional(),64});65export type WireOrder = z.infer<typeof order>;66export const orders = z.array(order);6768export const fill = z.object({69 id: z.string().min(1), side: z.enum(['BUY', 'SELL']), liquidity: z.enum(['MAKER', 'TAKER']), type: z.string().min(1), market: z.string().min(1),70 marketType: z.string(), price: cdec, size: cdec, fee: cdec, createdAt: iso, createdAtHeight: uintStr, orderId: z.string().nullable().optional(),71 subaccountNumber: z.number().int().nonnegative().optional(),72});73export type WireFill = z.infer<typeof fill>;74export const fills = z.object({ fills: z.array(fill) });7576export const fundingPayments = z.object({ fundingPayments: z.array(z.object({77 createdAt: iso, createdAtHeight: uintStr, ticker: z.string().min(1), oraclePrice: cdec, size: cdec, side: z.enum(['LONG', 'SHORT']), rate: cdec, payment: cdec,78})) });7980export const errorAnswer = z.object({ errors: z.array(z.object({ msg: z.string(), code: z.string().optional() })).min(1) });8182// ---- exact unit conversions (https://docs.dydx.xyz/concepts/trading/quantums ; v4-client-js src/clients/helpers/chain-helpers.ts) ----83/** USDC, the quote asset, has 6 decimals on chain: one quote quantum = 10^-6 USDC. */84export const QUOTE_ATOMIC_RESOLUTION = -6;85export interface MarketUnits { atomicResolution: number; quantumConversionExponent: number; stepBaseQuantums: number; subticksPerTick: number }8687/** int × 10^e as an exact decimal string */88const scale10 = (int: bigint, e: number): string => (e >= 0 ? (int * 10n ** BigInt(e)).toString() : fixed(int, -e));89/** value × 10^-e as an exact bigint; throws if it is not a whole number */90const unscale10 = (value: string, e: number): bigint => {91 if (e < 0) return toRaw(value, -e);92 const raw = toRaw(value, 0), d = 10n ** BigInt(e);93 if (raw % d !== 0n) throw new Error(`${value} is not a multiple of 10^${e}`);94 return raw / d;95};96/** exponent such that price = subticks × 10^priceExponent */97export const priceExponent = (u: MarketUnits): number => u.quantumConversionExponent + QUOTE_ATOMIC_RESOLUTION - u.atomicResolution;9899/** base quantums → size in base units (1 quantum = 10^atomicResolution base units) */100export const quantumsToSize = (quantums: bigint, u: MarketUnits): string => scale10(quantums, u.atomicResolution);101/** size in base units → base quantums; throws unless it is an exact multiple of the step (`stepBaseQuantums`) */102export function sizeToQuantums(size: string, u: MarketUnits): bigint {103 const q = unscale10(size, u.atomicResolution);104 if (q % BigInt(u.stepBaseQuantums) !== 0n) throw new Error(`size ${size} is not a multiple of the step`);105 return q;106}107/** subticks → price in USD per base unit */108export const subticksToPrice = (subticks: bigint, u: MarketUnits): string => scale10(subticks, priceExponent(u));109/** price → subticks; throws unless it is an exact multiple of the tick (`subticksPerTick`) */110export function priceToSubticks(price: string, u: MarketUnits): bigint {111 const s = unscale10(price, priceExponent(u));112 if (s % BigInt(u.subticksPerTick) !== 0n) throw new Error(`price ${price} is not a multiple of the tick`);113 return s;114}115/** tick and step derived from the protocol parameters (must equal the indexer's `tickSize` / `stepSize`) */116export const tickSizeOf = (u: MarketUnits): string => subticksToPrice(BigInt(u.subticksPerTick), u);117export const stepSizeOf = (u: MarketUnits): string => quantumsToSize(BigInt(u.stepBaseQuantums), u);118/** quote quantums → USDC */119export const quoteQuantumsToUsdc = (q: bigint): string => fixed(q, -QUOTE_ATOMIC_RESOLUTION);120