SPB Git

Repository Public

adapter.ts 12.3 KB 179 lines typescript

1// dYdX v4 (dYdX Chain) — READ side of the venue adapter, on the public Indexer API (see docs/v4/venues/dydx.md).2// Nothing here signs or sends anything: only GET requests and websocket subscriptions.3import { D, type DecStr } from '../../decimal.ts';4import type { AccountState, Book, Candle, FundingPayment, Instrument, PublicTrade, Ticker } from '../../models.ts';5import type { Capabilities, StreamStatus, StreamSub, VenueAdapter } from '../../venue.ts';6import { ds, venueError, venueHttp, type HttpAnswer } from '../http.ts';7import { DYDX, fillOf, instrumentId, instrumentOf, orderOf, parseAccount, positionOf, tickerOf, tradeOf, type DydxFill, type DydxOrder, type DydxPosition } from './map.ts';8import { DydxStream, type DydxStreamEvent, type WebSocketCtor } from './stream.ts';9import * as W from './wire.ts';10import { z } from 'zod';1112/** Pinned public endpoints — https://docs.dydx.xyz/interaction/endpoints */13export const DYDX_ENDPOINTS = {14  mainnet: { rest: 'https://indexer.dydx.trade/v4', ws: 'wss://indexer.dydx.trade/v4/ws', chainId: 'dydx-mainnet-1' },15  testnet: { rest: 'https://indexer.v4testnet.dydx.exchange/v4', ws: 'wss://indexer.v4testnet.dydx.exchange/v4/ws', chainId: 'dydx-testnet-4' },16} as const;17const RESOLUTION = { '1m': '1MIN', '5m': '5MINS', '15m': '15MINS', '1h': '1HOUR', '4h': '4HOURS', '1d': '1DAY' } as const;18/** the indexer serves at most 1000 rows per page on these lists */19const PAGE = 1000;20const MAX_PAGES = 25;2122export const DYDX_CAPABILITIES: Capabilities = {23  marketData: true, account: true, orders: false, orderTypes: [], tif: [], modify: false, leverage: false,24  marginModes: ['cross', 'isolated'], subaccounts: true, testnet: true, stream: true,25  notes: [26    'Read-only delivery: no order, cancel, deposit or withdrawal is built or sent.',27    'Trading would require a Cosmos secp256k1 key (dydx1… address), protobuf SIGN_DIRECT transactions (MsgPlaceOrder / MsgCancelOrder / MsgBatchCancel) broadcast to a validator, good-til-block handling for short-term orders and sequence handling for stateful ones; a trading-only key is possible through permissioned keys (authenticators) — see docs/v4/venues/dydx.md.',28    'The dYdX terms exclude the United States, Canada and the United Kingdom among others; the mainnet indexer answers account endpoints with HTTP 403 GEOBLOCKED from a blocked location (reported as VenueError "rejected"). Market data is not blocked.',29    'No separate mark price: mark = index = the protocol oracle price. Ticker.mid is null (read the book). Ticker.funding is the estimated next hourly rate.',30    'Account identifier: "dydx1…/<subaccountNumber>" (default 0). Subaccounts ≥ 128 are isolated-margin children of subaccount (n mod 128); they are read one at a time.',31    'Position.leverage, Position.margin are derived from venue figures (notional at oracle price ÷ subaccount equity; notional × initial margin fraction). Position.liquidationPrice, AccountState.maintenanceMargin and withdrawable are null: the indexer does not publish them.',32    'Order.createdAt is 0: the indexer gives the creation block height only. Indexer states BEST_EFFORT_OPENED / BEST_EFFORT_CANCELED are reported as "pending" (not final).',33    'FundingPayment.amount keeps the venue sign: positive = received by the account.',34  ],35};3637export interface DydxAdapterOptions { network?: 'mainnet' | 'testnet'; fetch?: typeof fetch; now?: () => number; restUrl?: string; wsUrl?: string; WebSocket?: WebSocketCtor }38export interface DydxAdapter extends VenueAdapter {39  positions(account: string): Promise<DydxPosition[]>;40  openOrders(account: string): Promise<DydxOrder[]>;41  orderHistory(account: string, o?: { limit?: number }): Promise<DydxOrder[]>;42  fills(account: string, o?: { start?: number; limit?: number }): Promise<DydxFill[]>;43  stream(sub: StreamSub, on: (ev: unknown) => void, onStatus: (s: StreamStatus) => void): () => void;44  /** every subaccount number that exists for an address (parents and isolated children) */45  subaccounts(address: string): Promise<number[]>;46  /** latest block height and time the indexer has processed — how far behind the chain the answers may be */47  height(): Promise<{ height: string; ts: number }>;48}4950export function createDydxAdapter(o: DydxAdapterOptions = {}): DydxAdapter {51  const network = o.network ?? 'mainnet', now = o.now ?? Date.now;52  const base = o.restUrl ?? DYDX_ENDPOINTS[network].rest;53  const http = venueHttp(DYDX, o.fetch);54  let streamConn: DydxStream | null = null;5556  // 403 GEOBLOCKED, 404 "not found" and 400 validation errors all come as { errors: [{ msg, code? }] }57  const on4xx = (a: HttpAnswer) => {58    let msg = a.text.slice(0, 200), code: string | undefined;59    try { const p = W.errorAnswer.safeParse(JSON.parse(a.text)); if (p.success) { msg = p.data.errors[0]!.msg; code = p.data.errors[0]!.code; } } catch { /* keep the raw text */ }60    if (a.status === 403 && code === 'GEOBLOCKED') return venueError(DYDX, 'rejected', `GEOBLOCKED: the indexer refuses this request from the current location (${msg.slice(0, 120)})`);61    return venueError(DYDX, 'rejected', `HTTP ${a.status}: ${msg.slice(0, 200)}`);62  };63  const get = <T>(path: string, schema: z.ZodType<T>) => http.get(`${base}${path}`, schema, { on4xx });64  const q = (p: Record<string, string | number | undefined>) => {65    const s = Object.entries(p).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&');66    return s ? `?${s}` : '';67  };68  const sym = (symbol: string) => {69    if (!/^[A-Za-z0-9,._-]{1,80}$/.test(symbol)) throw venueError(DYDX, 'rejected', 'invalid market ticker');70    return encodeURIComponent(symbol);71  };72  const markets = async () => (await get('/perpetualMarkets', W.perpetualMarkets)).markets;73  const whose = (account: string) => { const a = parseAccount(account); return { ...a, qs: { address: a.address, subaccountNumber: a.subaccountNumber } }; };7475  return {76    id: DYDX, network, capabilities: DYDX_CAPABILITIES,7778    async instruments(): Promise<Instrument[]> { return Object.values(await markets()).map(instrumentOf); },79    async tickers(): Promise<Ticker[]> { const ts = now(); return Object.values(await markets()).map((m) => tickerOf(m, ts)); },8081    async book(symbol: string, depth = 50): Promise<Book> {82      const b = await get(`/orderbooks/perpetualMarket/${sym(symbol)}`, W.orderbook);83      const side = (l: { price: string; size: string }[], dir: 1 | -1) =>84        l.map((x): [DecStr, DecStr] => [x.price, x.size]).sort((a, c) => dir * D.cmp(D.from(a[0]), D.from(c[0]))).slice(0, Math.max(1, depth));85      return { instrument: instrumentId(symbol), bids: side(b.bids, -1), asks: side(b.asks, 1), ts: now() };86    },8788    async trades(symbol: string, limit = 100): Promise<PublicTrade[]> {89      const t = await get(`/trades/perpetualMarket/${sym(symbol)}${q({ limit: Math.min(Math.max(1, limit), PAGE) })}`, W.trades);90      return t.trades.map((x) => tradeOf(symbol, x));91    },9293    async candles(symbol, interval, c): Promise<Candle[]> {94      const r = await get(`/candles/perpetualMarkets/${sym(symbol)}${q({ resolution: RESOLUTION[interval], limit: Math.min(Math.max(1, c.limit ?? 500), PAGE),95        fromISO: c.start === undefined ? undefined : new Date(c.start).toISOString(), toISO: c.end === undefined ? undefined : new Date(c.end).toISOString() })}`, W.candles);96      // volume is in base units (`baseTokenVolume`); oldest first97      return r.candles.map((x): Candle => ({ ts: Date.parse(x.startedAt), open: x.open, high: x.high, low: x.low, close: x.close, volume: x.baseTokenVolume })).sort((a, b) => a.ts - b.ts);98    },99100    async fundingHistory(symbol, f) {101      const out: { ts: number; rate: DecStr; premium: DecStr | null }[] = [];102      let before: number | undefined = f.end;103      for (let page = 0; page < MAX_PAGES; page++) {104        const r = await get(`/historicalFunding/${sym(symbol)}${q({ limit: PAGE, effectiveBeforeOrAt: before === undefined ? undefined : new Date(before).toISOString() })}`, W.historicalFunding);105        const rows = r.historicalFunding.map((x) => ({ ts: Date.parse(x.effectiveAt), rate: x.rate, premium: null }));   // hourly rate actually applied; the premium is not published106        for (const row of rows) if (row.ts >= f.start && (f.end === undefined || row.ts <= f.end)) out.push(row);107        const oldest = rows.reduce((m, x) => Math.min(m, x.ts), Infinity);108        if (rows.length < PAGE || oldest <= f.start) break;109        before = oldest - 1;110      }111      const seen = new Set<number>();112      return out.filter((x) => (seen.has(x.ts) ? false : (seen.add(x.ts), true))).sort((a, b) => a.ts - b.ts);113    },114115    async account(account: string): Promise<AccountState> {116      const a = whose(account);117      const s = (await get(`/addresses/${a.address}/subaccountNumber/${a.subaccountNumber}`, W.subaccountAnswer)).subaccount;118      const usdc = s.assetPositions.USDC;119      const collateral = usdc ? D.from(usdc.size) : D.from('0');120      return {121        venue: DYDX, account: a.id, equity: s.equity, freeCollateral: s.freeCollateral,122        collateral: ds(usdc?.side === 'SHORT' ? D.neg(D.abs(collateral)) : collateral),   // USDC balance of the subaccount (negative when borrowed against positions)123        marginUsed: ds(D.sub(D.from(s.equity), D.from(s.freeCollateral))),124        maintenanceMargin: null, withdrawable: null, ts: now(), source: 'venue',125      };126    },127128    async positions(account: string): Promise<DydxPosition[]> {129      const a = whose(account);130      const [s, m] = await Promise.all([get(`/addresses/${a.address}/subaccountNumber/${a.subaccountNumber}`, W.subaccountAnswer), markets()]);131      return Object.values(s.subaccount.openPerpetualPositions).filter((p) => p.status === 'OPEN').map((p) => {132        const market = m[p.market];133        if (!market) throw venueError(DYDX, 'bad_answer', `position in unknown market ${p.market}`);134        return positionOf(p, market, s.subaccount.equity);135      });136    },137138    async openOrders(account: string): Promise<DydxOrder[]> {139      const a = whose(account);140      return (await get(`/orders${q({ ...a.qs, status: 'OPEN,UNTRIGGERED,BEST_EFFORT_OPENED', limit: PAGE })}`, W.orders)).map(orderOf);141    },142    async orderHistory(account: string, h?: { limit?: number }): Promise<DydxOrder[]> {143      const a = whose(account);144      return (await get(`/orders${q({ ...a.qs, limit: Math.min(Math.max(1, h?.limit ?? 100), PAGE), returnLatestOrders: 'true' })}`, W.orders)).map(orderOf);145    },146    async fills(account: string, f?: { start?: number; limit?: number }): Promise<DydxFill[]> {147      const a = whose(account);148      const r = await get(`/fills${q({ ...a.qs, limit: Math.min(Math.max(1, f?.limit ?? 100), PAGE) })}`, W.fills);149      return r.fills.map(fillOf).filter((x) => f?.start === undefined || x.ts >= f.start);150    },151    async funding(account: string, f?: { start?: number }): Promise<FundingPayment[]> {152      const a = whose(account);153      const r = await get(`/fundingPayments${q({ ...a.qs, limit: PAGE })}`, W.fundingPayments);154      return r.fundingPayments155        .map((x): FundingPayment => ({ instrument: instrumentId(x.ticker), amount: x.payment, rate: x.rate, size: x.size, ts: Date.parse(x.createdAt) }))156        .filter((x) => f?.start === undefined || x.ts >= f.start);157    },158159    async subaccounts(address: string): Promise<number[]> {160      const a = parseAccount(address);161      return (await get(`/addresses/${a.address}`, W.addressAnswer)).subaccounts.map((s) => s.subaccountNumber).sort((x, y) => x - y);162    },163    async height() {164      const h = await get('/height', z.object({ height: z.string().regex(/^\d+$/), time: z.string() }));165      return { height: h.height, ts: Date.parse(h.time) };166    },167168    stream(sub: StreamSub, on: (ev: unknown) => void, onStatus: (s: StreamStatus) => void): () => void {169      streamConn ??= new DydxStream({ url: o.wsUrl ?? DYDX_ENDPOINTS[network].ws, WebSocket: o.WebSocket, now });170      const cb = on as (ev: DydxStreamEvent) => void;171      if (sub.type === 'account') return streamConn.subscribe('v4_subaccounts', parseAccount(sub.account).id, null, cb, onStatus);172      sym(sub.symbol);173      if (sub.type === 'book') return streamConn.subscribe('v4_orderbook', sub.symbol, null, cb, onStatus);174      if (sub.type === 'trades') return streamConn.subscribe('v4_trades', sub.symbol, null, cb, onStatus);175      return streamConn.subscribe('v4_markets', null, sub.symbol, cb, onStatus);176    },177  };178}179
Files main
Menu

Theme