SPB Git

Repository Public

route.ts 14.5 KB 191 lines typescript

1// Universal derivatives router — service side (docs/v4/ROUTER.md). SIMULATION ONLY: this file reads public market data2// through `DerivData`, public chain data through `ChainCostsReader`, and asks the paper / live services what their risk policy3// would SAY about the order (their `preview`, which places nothing). It builds no order, signs nothing, calls no signer and no4// venue trading endpoint. The estimates themselves are the pure functions of @spw/derivatives (router.ts).5import { gmxMarketByInstrument } from '@spw/derivatives/live'; // v86import { z } from 'zod';7import {8  D, OrderIntentSchema, ROUTER_FACTS, orderBodyFor, routeOrder,9  type ChainCosts, type DecStr, type ExecutionEstimate, type Instrument, type RiskResult, type RouteResult, type RouterAccount, type VenueId, type VenueSnapshot,10} from '@spw/derivatives';11import { UserError } from '../config.ts';12import { createChainCostsReader, type ChainCostsReader } from './chain-costs.ts';13import type { DerivData } from './data.ts';14import type { OrderBody, PaperService } from './paper.ts';15import type { VenueEntry, VenueRegistry } from './venues.ts';1617export const MAX_ROUTE_ACCOUNTS = 6;18/** instruments estimated per venue for one base (GMX lists one per pool) */19export const MAX_INSTRUMENTS_PER_VENUE = 8;20const FUNDING_RANGE_HOURS = 24;21const AccountIds = z.array(z.uuid()).max(MAX_ROUTE_ACCOUNTS).default([]);2223/** what the router needs from controlled live trading: the accounts, and its `preview` (risk engine on the venue's real state; nothing is placed) */24export interface LivePort {25  list(): Promise<{ id: string; venue: string; network: string; label: string; mode: string; account: string }[]>;26  preview(id: string, body: OrderBody): Promise<{ risk: RiskResult }>;27}28/** v8: what the GMX engine says about the wallet itself — GMX has no venue account, the wallet IS the account (gmx-exec.ts `tradability`) */29export interface GmxPort { tradability(): Promise<{ tradable: boolean; usdc: DecStr | null }> }30export interface PolicyCheck {31  accountId: string; label: string; kind: 'paper' | 'venue'; mode: string; network: string;32  /** null: the policy was not asked (the reason says why) */33  accepted: boolean | null; code: string | null; reason: string | null;34  source: 'risk_engine' | 'not_checked';35}36export interface VenueLimits { accepted: boolean; codes: string[]; note: string }37export type RoutedCandidate = ExecutionEstimate & { risk: { venue: VenueLimits; accounts: PolicyCheck[] } };38export interface RouteAnswer extends Omit<RouteResult, 'candidates'> { candidates: RoutedCandidate[]; errors: { venue: VenueId; message: string }[]; chain: ChainCosts }3940interface Acct { id: string; venue: VenueId; network: string; label: string; kind: 'paper' | 'venue'; mode: string; equity: DecStr | null; freeCollateral: DecStr | null; error: string | null }41const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 200);42const WALLET: Record<VenueId, { trading: 'paper' | 'none'; note: string }> = {43  hyperliquid: { trading: 'paper', note: 'this wallet trades Hyperliquid in simulation (paper account); live trading exists and stays off until the owner authorizes a venue account with his passkey' },44  gmx: { trading: 'none', note: 'GMX trading is not enabled on this wallet now (server settings, venue policy, or a market outside the pinned table): it only reads GMX' },45  dydx: { trading: 'none', note: 'market data only on this venue' },46};4748export class RouteService {49  readonly data: DerivData; readonly paper: PaperService; readonly registry: VenueRegistry;50  private readonly chain: ChainCostsReader; private readonly live: LivePort | null;51  private readonly gmx: GmxPort | null; private gmxSeen: { at: number; v: { tradable: boolean; usdc: DecStr | null } } | null = null; // v85253  constructor(o: { data: DerivData; paper: PaperService; chain?: ChainCostsReader; live?: LivePort | null; gmx?: GmxPort | null }) {54    this.data = o.data; this.paper = o.paper; this.registry = o.data.registry; this.live = o.live ?? null; this.gmx = o.gmx ?? null;55    this.chain = o.chain ?? createChainCostsReader({ now: o.data.now });56  }5758  /** bases listed on at least one venue, with the venues and instruments; a restricted venue is flagged, not hidden */59  async assets(): Promise<{ assets: { base: string; venues: { venue: VenueId; name: string; instruments: string[]; restriction: string | null }[]; candidateVenues: number }[]; errors: { venue: VenueId; message: string }[]; ts: number }> {60    const errors: { venue: VenueId; message: string }[] = [], by = new Map<string, Map<VenueId, string[]>>();61    await Promise.all(this.registry.list().map(async (v) => {62      try {63        for (const i of (await this.data.instruments(v.id)).value) {64          if (i.status === 'halted') continue;65          const base = i.base.toUpperCase(), m = by.get(base) ?? new Map<VenueId, string[]>();66          m.set(v.id, [...(m.get(v.id) ?? []), i.id]); by.set(base, m);67        }68      } catch (e) { errors.push({ venue: v.id, message: msg(e) }); }69    }));70    const order = this.registry.list().map((v) => v.id);71    const assets = [...by.entries()].map(([base, m]) => {72      const venues = order.filter((id) => m.has(id)).map((id) => { const v = this.registry.get(id); return { venue: id, name: v.name, instruments: m.get(id)!, restriction: v.restriction }; });73      return { base, venues, candidateVenues: venues.filter((v) => v.restriction === null).length };74    }).filter((a) => /^[A-Z0-9]{1,15}$/.test(a.base))75      // the bases that can be compared across venues first; then by name — an order of the list, not of the venues76      .sort((a, b) => b.candidateVenues - a.candidateVenues || b.venues.length - a.venues.length || a.base.localeCompare(b.base));77    if (errors.length === order.length && errors.length > 0) throw new UserError('venue_unavailable', errors[0]!.message, 503);78    return { assets, errors, ts: this.data.now() };79  }8081  private async accounts(ids: string[]): Promise<Acct[]> {82    if (!ids.length) return [];83    const [paper, live] = await Promise.all([this.paper.list(), this.live ? this.live.list().catch(() => []) : Promise.resolve([])]);84    return Promise.all(ids.map(async (id): Promise<Acct> => {85      const p = paper.find((a) => a.id === id);86      if (p) return { id, venue: p.venue, network: p.network, label: p.label, kind: 'paper', mode: p.mode, equity: p.state.equity, freeCollateral: p.state.freeCollateral, error: null };87      const l = live.find((a) => a.id === id);88      if (!l) throw new UserError('unknown_account', `unknown derivatives account ${id}`, 404);89      const base = { id, venue: l.venue as VenueId, network: l.network, label: l.label, kind: 'venue' as const, mode: l.mode };90      // the router's market data is mainnet: a testnet account's collateral is play money on another book91      if (l.network !== this.registry.get(l.venue).adapter.network) return { ...base, equity: null, freeCollateral: null, error: `this account is on ${l.network}; the router prices ${this.registry.get(l.venue).adapter.network} markets` };92      try { const w = await this.data.watch(base.venue, l.account); return { ...base, equity: w.state.equity, freeCollateral: w.state.freeCollateral, error: null }; }93      catch (e) { return { ...base, equity: null, freeCollateral: null, error: msg(e) }; }94    }));95  }9697  // >>> v8 GMX trading: when the wallet can create GMX orders (both server settings on, venue policy approved, pins intact) and the98  // instrument is one of the pinned markets, the router says so and counts the wallet's own USDC on Arbitrum as the collateral99  private async gmxWallet(inst: Instrument): Promise<{ wallet: VenueSnapshot['wallet']; account: RouterAccount | null } | null> {100    if (!this.gmx || inst.venue !== 'gmx' || !gmxMarketByInstrument(inst.id)) return null;101    if (!this.gmxSeen || this.data.now() - this.gmxSeen.at > 10_000) this.gmxSeen = { at: this.data.now(), v: await this.gmx.tradability().catch(() => ({ tradable: false, usdc: null })) };102    const t = this.gmxSeen.v;103    if (!t.tradable) return null;104    return { wallet: { trading: 'live', note: 'this wallet can create this GMX order: a wallet transaction approved with the passkey, executed later by a GMX keeper' },105      account: t.usdc === null ? null : { id: 'gmx-wallet', label: 'wallet on Arbitrum One (USDC)', kind: 'venue', mode: 'MANUAL_LIVE', equity: t.usdc, freeCollateral: t.usdc } };106  }107  // <<< v8 GMX trading108109  private async snapshot(v: VenueEntry, inst: Instrument, chain: ChainCosts, acct: Acct | undefined): Promise<VenueSnapshot> {110    const ticker = await this.data.ticker(inst), gw = await this.gmxWallet(inst); // v8111    const s: VenueSnapshot = {112      venue: v.id, restriction: v.restriction,113      wallet: acct?.kind === 'venue' && acct.mode === 'MANUAL_LIVE' && acct.error === null ? { trading: 'live', note: `« ${acct.label} » is a venue account authorized for live trading (passkey per order)` } : gw?.wallet ?? WALLET[v.id],114      instrument: inst, ticker, book: null, gmx: null, fundingRange: null, chain,115      account: acct && acct.equity !== null && acct.freeCollateral !== null ? { id: acct.id, label: acct.label, kind: acct.kind, mode: acct.mode, equity: acct.equity, freeCollateral: acct.freeCollateral } satisfies RouterAccount : gw?.account ?? null,116    };117    const soft = async (fn: () => Promise<void>): Promise<void> => { try { await fn(); } catch (e) { s.unavailable = msg(e); } };118    if (v.facts.hasOrderBook) await soft(async () => { s.book = await this.data.book(inst); });119    else await soft(async () => { s.gmx = await this.data.liquidity(inst); });120    // the last day of the hourly rate, to say how much the carry can move; without it the router assumes ±100 %121    try {122      const h = (await this.data.funding(inst, FUNDING_RANGE_HOURS)).value;123      if (h.length >= 2) {124        const perHour = h.map((x) => D.div(D.mul(D.from(x.rate), D.from(3600n)), D.from(BigInt(inst.fundingIntervalSec)), 18, 'half_even'));125        s.fundingRange = { minPerHour: D.str(perHour.reduce((a, b) => D.min(a, b))), maxPerHour: D.str(perHour.reduce((a, b) => D.max(a, b))), hours: FUNDING_RANGE_HOURS };126      }127    } catch { /* no history: the estimate says so */ }128    return s;129  }130131  /** what an account's own risk policy says about the order — through the service's `preview`, which stores and places nothing */132  private async policy(e: ExecutionEstimate, a: Acct): Promise<PolicyCheck> {133    const head = { accountId: a.id, label: a.label, kind: a.kind, mode: a.mode, network: a.network };134    const skip = (reason: string): PolicyCheck => ({ ...head, accepted: null, code: null, reason, source: 'not_checked' });135    if (a.error) return skip(a.error);136    const body = orderBodyFor(e);137    if (!body) return skip(e.order ? 'no order of this account\'s kind can be formed from this estimate' : 'the order could not be sized on this instrument');138    try {139      const r = a.kind === 'paper' ? await this.paper.preview(a.id, { ...body, triggerPrice: null, tif: body.type === 'market' ? 'ioc' : 'gtc' })140        : await this.live!.preview(a.id, { ...body, triggerPrice: null, tif: body.type === 'market' ? 'ioc' : 'gtc' });141      return r.risk.ok ? { ...head, accepted: true, code: null, reason: null, source: 'risk_engine' } : { ...head, accepted: false, code: r.risk.code, reason: r.risk.reason, source: 'risk_engine' };142    } catch (err) {143      // the service refused before its risk engine ran (read-only account, mainnet off, venue unreadable): that refusal is the answer144      if (err instanceof UserError) return { ...head, accepted: false, code: err.code, reason: err.message.slice(0, 240), source: 'risk_engine' };145      throw err;146    }147  }148149  async route(input: unknown): Promise<RouteAnswer> {150    const raw = z.record(z.string(), z.unknown()).parse(input ?? {}), { accountIds: ids, ...rest } = raw;151    const intent = OrderIntentSchema.parse(rest), accountIds = [...new Set(AccountIds.parse(ids))];152    const want = intent.base.toUpperCase(), errors: { venue: VenueId; message: string }[] = [];153    const accounts = await this.accounts(accountIds);154    // the ETH price that converts chain costs comes from the same snapshot as everything else155    const eth = await this.ethPrice();156    const chain = await this.chain.read(eth);157    const snaps: VenueSnapshot[] = [];158    await Promise.all(this.registry.list().map(async (v) => {159      try {160        const insts = (await this.data.instruments(v.id)).value.filter((i) => i.base.toUpperCase() === want).slice(0, MAX_INSTRUMENTS_PER_VENUE);161        const acct = accounts.find((a) => a.venue === v.id);162        for (const s of await Promise.all(insts.map((i) => this.snapshot(v, i, chain, acct)))) snaps.push(s);163      } catch (e) { errors.push({ venue: v.id, message: msg(e) }); }164    }));165    if (snaps.length === 0 && errors.length > 0) throw new UserError('venue_unavailable', errors[0]!.message, 503);166    const order = this.registry.list().map((v) => v.id);167    snaps.sort((a, b) => order.indexOf(a.venue) - order.indexOf(b.venue) || a.instrument.id.localeCompare(b.instrument.id));168    const r = routeOrder(intent, snaps, { now: this.data.now() });169    const candidates = await Promise.all(r.candidates.map(async (e): Promise<RoutedCandidate> => {170      const blockers = e.eligibility.reasons.filter((x) => x.kind === 'blocker').map((x) => x.code);171      const mine = accounts.filter((a) => a.venue === e.venue);172      return { ...e, risk: {173        venue: { accepted: blockers.length === 0, codes: blockers, note: blockers.length === 0 ? `within the limits ${ROUTER_FACTS[e.venue].name} publishes for this instrument (lot, minimum, leverage, liquidity) as far as the snapshot shows` : 'refused by the venue\'s own limits or by the data' },174        accounts: await Promise.all(mine.map((a) => this.policy(e, a))),175      } };176    }));177    const warnings = [...r.warnings];178    if (errors.length) warnings.push(`Not read: ${errors.map((e) => e.venue).join(', ')}. The comparison is missing those venues.`);179    for (const a of accounts) if (!r.candidates.some((e) => e.venue === a.venue)) warnings.push(`« ${a.label} » is on ${a.venue}, which lists no ${want} perpetual: its policy was not asked.`);180    return { ...r, candidates, warnings, errors, chain };181  }182183  private async ethPrice(): Promise<{ value: DecStr; source: string } | null> {184    for (const [venue, id, source] of [['hyperliquid', 'hyperliquid:ETH', 'Hyperliquid ETH mark price'], ['gmx', 'gmx:ETH/USD [WETH-USDC]', 'GMX oracle mid of ETH/USD']] as const) {185      if (!this.registry.has(venue)) continue;186      try { const t = (await this.data.tickers(venue)).value.find((x) => x.instrument === id); if (t) return { value: t.mark, source }; } catch { /* next */ }187    }188    return null;189  }190}191
Files main
Menu

Theme