Repository Public
1// Venue registry of the derivatives module (contract: docs/v4/DERIVATIVES-ARCHITECTURE.md, "API contract (wave 2").2// READ side only: the registry hands out a narrowed view of each adapter (market data, account reads, stream). The adapters'3// trading objects, signing helpers and raw HTTP clients are not reachable from here, so nothing that goes through this registry can build,4// sign or send an order to a venue.5import {6 createDydxAdapter, createGmxAdapter, createHyperliquidAdapter, VenueError,7 type Capabilities, type DecStr, type GmxLiquidity, type VenueAdapter, type VenueId,8} from '@spw/derivatives';9import { UserError } from '../config.ts';1011/** what the service may call on a venue: reads and the public stream — never `trading`, never a raw HTTP client */12export type ReadAdapter = Pick<VenueAdapter, 'id' | 'network' | 'capabilities' | 'instruments' | 'tickers' | 'book' | 'trades' | 'candles' | 'fundingHistory'13 | 'account' | 'positions' | 'openOrders'> & { stream?: VenueAdapter['stream']; liquidity?: (symbol?: string) => Promise<GmxLiquidity[]> };1415/** facts of docs/v4/venues/contract-differences.md used by the comparison table (base fee tier, checked 2026-11-10) */16export interface VenueFacts {17 /** base-tier taker fee in basis points; null when the venue has no maker/taker schedule (read from the venue instead) */18 takerFeeBps: DecStr | null; makerFeeBps: DecStr | null;19 /** fee rates used by a paper account on this venue (fractions of notional) */20 paperFees: { taker: DecStr; maker: DecStr } | null;21 hasOrderBook: boolean;22 notes: string[];23}2425export interface VenueEntry {26 id: VenueId; name: string; adapter: ReadAdapter;27 /** a non-null text means: market data only — no account read, no paper account; the UI shows the text */28 restriction: string | null;29 facts: VenueFacts;30}3132export type VenueStatus = 'ok' | 'degraded' | 'down';33export interface VenueHealth { status: VenueStatus; lastOkAt: number | null; lastErrorAt: number | null; lastError: string | null; consecutiveFailures: number }3435export const DYDX_RESTRICTION = 'dYdX is served for market data only: its terms of use exclude Canada (and the United States and the United Kingdom) and its indexer geoblocks account reads from the owner\'s network. No account view and no paper account are offered on this venue, so that it cannot be mistaken for a venue the owner may trade on.';3637/** failures in a row before a venue is reported `down` (1 … DOWN_AFTER − 1 = `degraded`) */38export const DOWN_AFTER = 3;39/** VenueError codes that say something about the venue's availability (a refused or unsupported request does not) */40const HEALTH_CODES = new Set(['unavailable', 'bad_answer', 'stale', 'rate_limited']);4142function readOnly(a: VenueAdapter & { liquidity?: ReadAdapter['liquidity'] }, restriction: string | null): ReadAdapter {43 const refuse = async (): Promise<never> => { throw new UserError('venue_restricted', restriction ?? 'account reads are not available on this venue', 403); };44 const accountOk = restriction === null && a.capabilities.account;45 const capabilities: Capabilities = restriction === null ? a.capabilities46 // a restricted venue declares only what the wallet really offers on it (principle 7)47 : { ...a.capabilities, account: false, orders: false, orderTypes: [], tif: [], modify: false, leverage: false, notes: [restriction, ...a.capabilities.notes] };48 const view: ReadAdapter = {49 id: a.id, network: a.network, capabilities,50 instruments: () => a.instruments(), tickers: () => a.tickers(), book: (s, d) => a.book(s, d), trades: (s, l) => a.trades(s, l),51 candles: (s, i, o) => a.candles(s, i, o), fundingHistory: (s, o) => a.fundingHistory(s, o),52 account: accountOk ? (x) => a.account(x) : refuse, positions: accountOk ? (x) => a.positions(x) : refuse, openOrders: accountOk ? (x) => a.openOrders(x) : refuse,53 };54 if (a.stream) {55 const stream = a.stream.bind(a);56 view.stream = (sub, on, onStatus) => {57 if (sub.type === 'account') throw new UserError('unsupported', 'account streams are not part of this wave', 400);58 return stream(sub, on, onStatus);59 };60 }61 if (a.liquidity) view.liquidity = (s) => a.liquidity!(s);62 return view;63}6465export class VenueRegistry {66 readonly entries: Map<VenueId, VenueEntry>;67 private readonly healthOf = new Map<VenueId, VenueHealth>();68 private readonly now: () => number;6970 constructor(entries: VenueEntry[], now: () => number = Date.now) {71 this.entries = new Map(entries.map((e) => [e.id, e]));72 this.now = now;73 for (const e of entries) this.healthOf.set(e.id, { status: 'ok', lastOkAt: null, lastErrorAt: null, lastError: null, consecutiveFailures: 0 });74 }7576 list(): VenueEntry[] { return [...this.entries.values()]; }77 has(id: string): id is VenueId { return this.entries.has(id as VenueId); }78 get(id: string): VenueEntry {79 const e = this.entries.get(id as VenueId);80 if (!e) throw new UserError('unknown_venue', `unknown venue '${String(id).slice(0, 40)}'`, 404);81 return e;82 }8384 /** account reads (watch, future venue accounts) are allowed only where the adapter declares them and nothing restricts the venue */85 accountAllowed(id: string): boolean { const e = this.get(id); return e.restriction === null && e.adapter.capabilities.account; }86 /** throws the restriction text when the venue is market data only */87 assertAccountAllowed(id: string): VenueEntry {88 const e = this.get(id);89 if (e.restriction !== null) throw new UserError('venue_restricted', e.restriction, 403);90 if (!e.adapter.capabilities.account) throw new UserError('venue_no_account', `${e.name} does not expose account reads`, 422);91 return e;92 }9394 health(id: VenueId): VenueHealth { return { ...this.healthOf.get(id)! }; }9596 /** every venue call of the service goes through here: the outcome feeds the venue's health */97 async call<T>(id: VenueId, fn: (a: ReadAdapter) => Promise<T>): Promise<T> {98 const e = this.get(id), h = this.healthOf.get(id)!;99 try {100 const out = await fn(e.adapter);101 h.lastOkAt = this.now(); h.consecutiveFailures = 0; h.status = 'ok'; h.lastError = null;102 return out;103 } catch (err) {104 if (err instanceof VenueError && HEALTH_CODES.has(err.code)) {105 // the local weight budget refusing a request says nothing about the venue being down: it degrades, it never downs106 const local = err.code === 'rate_limited';107 h.consecutiveFailures = local ? Math.min(h.consecutiveFailures + 1, DOWN_AFTER - 1) : h.consecutiveFailures + 1;108 h.lastErrorAt = this.now(); h.lastError = `${err.code}: ${err.message}`.slice(0, 200);109 h.status = h.consecutiveFailures >= DOWN_AFTER ? 'down' : 'degraded';110 } else if (!(err instanceof VenueError) && !(err instanceof UserError)) {111 h.consecutiveFailures += 1; h.lastErrorAt = this.now(); h.lastError = `error: ${(err as Error)?.message ?? String(err)}`.slice(0, 200);112 h.status = h.consecutiveFailures >= DOWN_AFTER ? 'down' : 'degraded';113 }114 throw err;115 }116 }117}118119export const HYPERLIQUID_FACTS: VenueFacts = {120 takerFeeBps: '4.5', makerFeeBps: '1.5', paperFees: { taker: '0.00045', maker: '0.00015' }, hasOrderBook: true,121 notes: [122 'On-chain central limit order book; immediate match.',123 'Mark price = median of oracle + EMA of (book mid − oracle), book median, and a weighted median of CEX perp mids; the oracle is USDT-denominated (quanto: USDT PnL paid in USDC, no conversion).',124 'Funding is paid every hour, peer to peer, on size × oracle price × rate; the hourly rate is one eighth of an 8 h rate; cap 0.5 % per hour.',125 'Cross (default) or isolated margin; maintenance margin is half of the initial margin at maximum leverage (tiered by size).',126 'Fee shown is the base tier (taker 0.045 %, maker 0.015 %); volume tiers and staking discounts exist.',127 'Liquidation through the book, then the liquidator vault; no separate liquidation fee, the maintenance margin is not returned in a backstop.',128 ],129};130export const DYDX_FACTS: VenueFacts = {131 takerFeeBps: '5', makerFeeBps: '1', paperFees: null, hasOrderBook: true,132 notes: [133 'On-chain order book (short-term orders live in validator memory); the book is thin compared with Hyperliquid.',134 'No separate mark price: margin and liquidation use the oracle price (validators\' aggregate of spot exchanges, USD).',135 'Funding is paid every hour on size × oracle price × rate; the rate shown is the estimated next hourly rate.',136 'Cross margin inside a subaccount; no leverage setting (50× bound from a 2 % initial margin); liquidation penalty up to 1.5 %.',137 'Fee shown is the base tier (taker 0.05 %, maker 0.01 %).',138 'Eligibility: the terms exclude the United States, Canada and the United Kingdom.',139 ],140};141export const GMX_FACTS: VenueFacts = {142 takerFeeBps: null, makerFeeBps: null, paperFees: null, hasOrderBook: false,143 notes: [144 'No order book: trades execute against a GM liquidity pool at the signed oracle price (longs open at max, close at min), adjusted by price impact; one instrument per pool.',145 'Execution takes two transactions (request, then keeper execution at a later oracle price); an order can be cancelled or frozen instead of filled.',146 'Funding accrues per second and is quoted per hour; the larger open-interest side also pays a borrowing fee.',147 'Isolated margin per position only; collateral is the pool\'s long or short token (it may move with the market).',148 'Position fee 0.04 % when the trade improves the long/short balance, 0.06 % otherwise (the higher one is shown); plus price impact, an execution fee in ETH and Arbitrum gas.',149 'Maximum leverage is the contract bound; the effective maximum is lower and falls as open interest grows.',150 ],151};152153/** the real adapters, on public read endpoints only (mainnet). `fetch` is injectable for the smoke script and tests. */154export function createDefaultRegistry(o: { fetch?: typeof fetch; now?: () => number } = {}): VenueRegistry {155 const now = o.now ?? Date.now, f = o.fetch === undefined ? {} : { fetch: o.fetch };156 // no `assets` and no `trading` option: `adapter.trading` stays undefined, and the read-only view does not expose it anyway157 return createRegistry({158 hyperliquid: createHyperliquidAdapter({ network: 'mainnet', now, ...f }),159 gmx: createGmxAdapter({ chain: 'arbitrum', now, ...f }),160 dydx: createDydxAdapter({ network: 'mainnet', now, ...f }),161 }, now);162}163164/** registry over arbitrary adapters (tests: fixture-backed fakes). The same narrowing and the same dYdX restriction apply. */165export function createRegistry(adapters: Partial<Record<VenueId, VenueAdapter & { liquidity?: ReadAdapter['liquidity'] }>>, now: () => number = Date.now): VenueRegistry {166 const out: VenueEntry[] = [];167 if (adapters.hyperliquid) out.push({ id: 'hyperliquid', name: 'Hyperliquid', adapter: readOnly(adapters.hyperliquid, null), restriction: null, facts: HYPERLIQUID_FACTS });168 if (adapters.gmx) out.push({ id: 'gmx', name: 'GMX v2 (Arbitrum)', adapter: readOnly(adapters.gmx, null), restriction: null, facts: GMX_FACTS });169 if (adapters.dydx) out.push({ id: 'dydx', name: 'dYdX v4', adapter: readOnly(adapters.dydx, DYDX_RESTRICTION), restriction: DYDX_RESTRICTION, facts: DYDX_FACTS });170 return new VenueRegistry(out, now);171}172