Repository Public
1// dYdX v4 → normalized models. Pure functions shared by the REST adapter and the websocket stream.2// Every figure stays a decimal string; arithmetic goes through `D` (bigint).3import { D } from '../../decimal.ts';4import type { Fill, Instrument, Order, Position, PublicTrade, Ticker } from '../../models.ts';5import { ds, venueError } from '../http.ts';6import { stepSizeOf, tickSizeOf, type PerpetualMarket, type PerpetualPosition, type WireFill, type WireOrder } from './wire.ts';7import type { z } from 'zod';8import type { trade as wireTrade } from './wire.ts';910export const DYDX = 'dydx' as const;11const bad = (m: string) => venueError(DYDX, 'bad_answer', m);12export const instrumentId = (ticker: string): string => `${DYDX}:${ticker}`;1314/** dYdX funding is computed and paid once per hour (https://docs.dydx.xyz/concepts/trading/funding). */15export const DYDX_FUNDING_INTERVAL_SEC = 3600;16export const DYDX_SETTLEMENT = 'USDC-margined linear perpetual on dYdX Chain: PnL and funding settle in USDC (Noble USDC over IBC) in the subaccount; no expiry, no delivery';17export const DYDX_MARK_METHOD = 'no separate mark price: the protocol oracle price (validator-aggregated index of exchange prices, updated per block) values positions, margin, liquidations and conditional triggers';18/** Subaccounts 128 and above are the front-end's isolated-margin children of subaccount (n mod 128). */19export const DYDX_ISOLATED_FROM = 128;2021export function instrumentOf(m: PerpetualMarket): Instrument {22 // the indexer's display strings must agree with the protocol parameters they are derived from23 if (tickSizeOf(m) !== m.tickSize) throw bad(`${m.ticker}: tickSize ${m.tickSize} ≠ subticksPerTick-derived ${tickSizeOf(m)}`);24 if (stepSizeOf(m) !== m.stepSize) throw bad(`${m.ticker}: stepSize ${m.stepSize} ≠ stepBaseQuantums-derived ${stepSizeOf(m)}`);25 const imf = D.from(m.initialMarginFraction);26 if (imf.m <= 0n) throw bad(`${m.ticker}: initialMarginFraction ${m.initialMarginFraction}`);27 const dash = m.ticker.lastIndexOf('-');28 if (dash <= 0) throw bad(`ticker ${m.ticker}`);29 return {30 venue: DYDX, symbol: m.ticker, id: instrumentId(m.ticker), base: m.ticker.slice(0, dash), quote: m.ticker.slice(dash + 1), kind: 'perpetual',31 collateral: 'USDC', settlement: DYDX_SETTLEMENT,32 tickSize: m.tickSize, lotSize: m.stepSize, minOrderSize: m.stepSize, minNotional: null,33 // base initial margin fraction; it rises with open interest between openInterestLowerCap and openInterestUpperCap when those are set34 maxLeverage: ds(D.div(D.from('1'), imf, 4, 'down')),35 marginModes: m.marketType === 'ISOLATED' ? ['isolated'] : ['cross', 'isolated'],36 fundingIntervalSec: DYDX_FUNDING_INTERVAL_SEC, markPriceMethod: DYDX_MARK_METHOD,37 // only ACTIVE accepts every order type; the other states (post-only, cancel-only, paused, initializing, final settlement) are not tradable for a taker38 status: m.status === 'ACTIVE' ? 'active' : 'halted',39 };40}4142export function tickerOf(m: PerpetualMarket, ts: number): Ticker {43 const oracle = D.from(m.oraclePrice), change = D.from(m.priceChange24H), prev = D.sub(oracle, change);44 return {45 instrument: instrumentId(m.ticker), mark: m.oraclePrice, index: m.oraclePrice, mid: null,46 funding: m.nextFundingRate, // estimate of the next hourly rate; positive = longs pay shorts47 openInterest: m.openInterest, // base units, one side48 volume24h: m.volume24H, // USD49 change24h: prev.m > 0n ? ds(D.div(change, prev, 8, 'half_even')) : null, // fraction of the oracle price 24 h ago50 ts,51 };52}5354export const tradeOf = (ticker: string, t: z.infer<typeof wireTrade>): PublicTrade =>55 ({ instrument: instrumentId(ticker), price: t.price, size: t.size, side: t.side === 'BUY' ? 'buy' : 'sell', ts: Date.parse(t.createdAt), id: t.id });5657const ORDER_TYPES: Record<string, Order['type']> = {58 LIMIT: 'limit', MARKET: 'market', STOP_LIMIT: 'stop_limit', STOP_MARKET: 'stop_market', TAKE_PROFIT: 'take_profit_limit', TAKE_PROFIT_MARKET: 'take_profit_market',59};60export interface DydxOrderDetail { status: string; type: string; timeInForce: string; orderFlags: string; clobPairId: string;61 goodTilBlock: string | null; goodTilBlockTime: string | null; subaccountNumber: number | null; removalReason: string | null; optimisticFill: boolean }62export type DydxOrder = Order & { dydx: DydxOrderDetail };6364export function orderOf(o: WireOrder): DydxOrder {65 const type = ORDER_TYPES[o.type];66 if (!type) throw venueError(DYDX, 'unsupported', `order ${o.id}: type ${o.type} has no normalized equivalent`);67 const filledStr = o.totalFilled ?? o.totalOptimisticFilled;68 if (filledStr === undefined) throw bad(`order ${o.id}: no filled amount`);69 const filled = D.from(filledStr);70 let status: Order['status'];71 switch (o.status) {72 case 'OPEN': status = filled.m > 0n ? 'partially_filled' : 'open'; break;73 case 'UNTRIGGERED': status = 'open'; break; // resting conditional order74 case 'FILLED': status = 'filled'; break;75 case 'CANCELED': status = 'cancelled'; break;76 // "best effort" states are the indexer's unconfirmed view of a short-term order: not final, so never reported as final77 case 'BEST_EFFORT_OPENED': case 'BEST_EFFORT_CANCELED': case 'PENDING': status = 'pending'; break;78 default: throw bad(`order ${o.id}: status ${o.status}`);79 }80 let tif: Order['tif'];81 if (o.postOnly) tif = 'alo';82 else if (o.timeInForce === 'GTT') tif = 'gtc';83 else if (o.timeInForce === 'IOC' || o.timeInForce === 'FOK') tif = 'ioc';84 else throw bad(`order ${o.id}: timeInForce ${o.timeInForce}`);85 const updatedAt = o.updatedAt ? Date.parse(o.updatedAt) : 0;86 return {87 instrument: instrumentId(o.ticker), side: o.side === 'BUY' ? 'buy' : 'sell', type, size: o.size, price: o.price,88 triggerPrice: o.triggerPrice ?? null, reduceOnly: o.reduceOnly, tif, clientId: o.clientId,89 venueOrderId: o.id, status, filled: filledStr, avgFillPrice: null,90 createdAt: 0, // the indexer reports the creation block height only, not a time91 updatedAt, rejectReason: o.removalReason ?? null,92 dydx: { status: o.status, type: o.type, timeInForce: o.timeInForce, orderFlags: o.orderFlags, clobPairId: o.clobPairId,93 goodTilBlock: o.goodTilBlock ?? null, goodTilBlockTime: o.goodTilBlockTime ?? null, subaccountNumber: o.subaccountNumber ?? null,94 removalReason: o.removalReason ?? null, optimisticFill: o.totalFilled === undefined },95 };96}9798export type DydxFill = Fill & { dydx: { liquidity: 'MAKER' | 'TAKER'; type: string } };99export const fillOf = (f: WireFill): DydxFill => ({100 instrument: instrumentId(f.market), orderId: f.orderId ?? null, clientId: null, side: f.side === 'BUY' ? 'buy' : 'sell', price: f.price, size: f.size,101 fee: f.fee, // USDC; negative = maker rebate102 feeAsset: 'USDC', closedPnl: null, ts: Date.parse(f.createdAt), id: f.id,103 liquidation: f.type === 'LIQUIDATED' || f.type === 'DELEVERAGED', // this account was liquidated / deleveraged104 dydx: { liquidity: f.liquidity, type: f.type },105});106107export type DydxPosition = Position & { dydx: { subaccountNumber: number; notional: string; leverageDefined: boolean; netFunding: string } };108/** `equity` is the subaccount's equity as reported by the indexer; `market` supplies the oracle price and the margin fraction. */109export function positionOf(p: PerpetualPosition, market: PerpetualMarket, equity: string): DydxPosition {110 const size = D.abs(D.from(p.size)), notional = D.mul(size, D.from(market.oraclePrice)), eq = D.from(equity);111 const leverageDefined = eq.m > 0n;112 return {113 instrument: instrumentId(p.market), side: p.side === 'LONG' ? 'long' : 'short', size: ds(size), entryPrice: p.entryPrice, markPrice: market.oraclePrice,114 liquidationPrice: null, // not published by the venue; any figure would be an estimate115 // derived: notional at the oracle price over the subaccount's equity (cross: the whole subaccount backs every position)116 leverage: leverageDefined ? ds(D.div(notional, eq, 4, 'half_even')) : '0',117 marginMode: p.subaccountNumber >= DYDX_ISOLATED_FROM ? 'isolated' : 'cross',118 margin: ds(D.mul(notional, D.from(market.initialMarginFraction))), // derived: initial margin requirement at the base fraction119 unrealizedPnl: p.unrealizedPnl, realizedPnl: p.realizedPnl,120 fundingPaid: ds(D.neg(D.from(p.netFunding))), // venue reports net funding received; paid = −received121 source: 'venue',122 dydx: { subaccountNumber: p.subaccountNumber, notional: ds(notional), leverageDefined, netFunding: p.netFunding },123 };124}125126/** `dydx1…` or `dydx1…/<subaccountNumber>` (default subaccount 0). The address is not checksummed here; the venue rejects unknown ones. */127export function parseAccount(account: string): { address: string; subaccountNumber: number; id: string } {128 const m = /^(dydx1[02-9ac-hj-np-z]{38})(?:\/(\d{1,6}))?$/.exec(account);129 if (!m) throw venueError(DYDX, 'rejected', 'account must be a dydx1… address, optionally followed by /<subaccount number>');130 const n = Number(m[2] ?? '0');131 if (n > 128_000) throw venueError(DYDX, 'rejected', 'subaccount number out of range (0–128000)');132 return { address: m[1]!, subaccountNumber: n, id: `${m[1]}/${n}` };133}134