SPB Git

Repository Public

mockRouter.ts 11.1 KB 122 lines typescript

1/**2 * Router routes of the in-browser mock (DEVELOPMENT BUILDS ONLY, see mock.ts): `POST /derivatives/route` and3 * `GET /derivatives/route/assets`. The estimates are the repository's REAL router (`packages/derivatives/src/router.ts`) run on4 * the mock's simulated books and on GMX market state rebuilt from the recorded fixtures with the adapter's own mapping; the5 * risk view is the mock's real `checkOrder` preview. What is simulated here is the data, not the arithmetic. Chain costs are6 * fixed sample values (no RPC in the browser). Nothing here is evidence that the real backend behaves the same way.7 */8import gmxConfigRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-markets-config.json?raw';9import gmxTickersRaw from '../../../../packages/derivatives/test/fixtures/gmx/v1-markets-tickers.json?raw';10import gmxValuesRaw from '../../../../packages/derivatives/test/fixtures/gmx-router/v1-markets-values.json?raw';11import { D } from '../../../../packages/derivatives/src/decimal.ts';12import { ROUTER_FACTS, orderBodyFor, routeOrder, type ChainCosts, type VenueSnapshot } from '../../../../packages/derivatives/src/router.ts';13import type { GmxLiquidity } from '../../../../packages/derivatives/src/venues/gmx/adapter.ts';14import type { Book, Instrument, RiskResult, Ticker, VenueId } from './types';1516const E30 = D.from(10n ** 30n);17const f30 = (raw: string | bigint): string => D.str(D.div(D.from(BigInt(raw)), E30, 30, 'down'));18const tok = (raw: string, dec: number): string => D.str(D.div(D.from(BigInt(raw)), D.from(10n ** BigInt(dec)), dec, 'down'));19const body = (raw: string): any => JSON.parse(JSON.parse(raw).text);20const DYDX_NOTE = 'market data only on this venue';21const WALLET: Record<VenueId, VenueSnapshot['wallet']> = {22  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' },23  gmx: { trading: 'none', note: 'this wallet only READS GMX: no GMX order can be built or sent from it today' },24  dydx: { trading: 'none', note: DYDX_NOTE },25};2627export interface MockMarket { inst: Instrument; ticker: Ticker; book: Book | null }28export interface MockAccount { id: string; venue: VenueId; label: string; mode: string; equity: string; freeCollateral: string; preview: (body: Record<string, unknown>) => { status: number; json: any } }29export interface RouterMockDeps {30  instruments: () => Instrument[];31  /** the simulated markets of one base asset, advanced to now */ markets: (base: string) => MockMarket[];32  restriction: (venue: VenueId) => string | null;33  account: (id: string) => MockAccount | null;34  /** decimals of a GMX index token */ gmxDecimals: (base: string) => number;35}3637/** the adapter's `liquidity()` mapping (packages/derivatives/src/venues/gmx/adapter.ts) on the recorded tickers and configuration */38function gmxState(decimals: (base: string) => number): Map<string, Omit<GmxLiquidity, 'ts' | 'oracleMin' | 'oracleMax'>> {39  const out = new Map<string, Omit<GmxLiquidity, 'ts' | 'oracleMin' | 'oracleMax'>>();40  const cfg = new Map((body(gmxConfigRaw) as any[]).map((g) => [String(g.marketTokenAddress).toLowerCase(), g]));41  // the virtual inventory comes from a later recording that holds two markets only: the others say "unknown", as the real service does when /markets/values cannot be read42  const vals = new Map((body(gmxValuesRaw) as any[]).map((v) => [String(v.marketTokenAddress).toLowerCase(), v]));43  for (const t of body(gmxTickersRaw) as any[]) {44    const g = cfg.get(String(t.marketTokenAddress).toLowerCase()), base = String(t.symbol).split('/')[0]!;45    if (!g) continue;46    const dec = decimals(base), v = vals.get(String(t.marketTokenAddress).toLowerCase());47    out.set(`gmx:${t.symbol}`, {48      instrument: `gmx:${t.symbol}`, symbol: t.symbol, market: t.marketTokenAddress,49      openInterestLongUsd: f30(t.longInterestUsd), openInterestShortUsd: f30(t.shortInterestUsd), maxOpenInterestLongUsd: f30(g.maxOpenInterestLong), maxOpenInterestShortUsd: f30(g.maxOpenInterestShort),50      openInterestLongTokens: tok(t.longInterestInTokens, dec), openInterestShortTokens: tok(t.shortInterestInTokens, dec), useOpenInterestInTokensForBalance: g.useOpenInterestInTokensForBalance ?? null,51      virtualInventory: { applies: typeof g.virtualIndexTokenId === 'string' ? BigInt(g.virtualIndexTokenId) !== 0n : null, tokens: v ? tok(v.virtualInventoryForPositionsInTokens, dec) : null, usd: v ? f30(v.virtualInventoryForPositions) : null },52      availableLiquidityLongUsd: f30(t.availableLiquidityLong), availableLiquidityShortUsd: f30(t.availableLiquidityShort), capacityLongUsd: f30(t.capacityLong.availableLiquidity), capacityShortUsd: f30(t.capacityShort.availableLiquidity),53      capacityLimitedBy: { long: t.capacityLong.limitingFactor, short: t.capacityShort.limitingFactor },54      fundingRateLongPerHour: f30(t.fundingRateLong), fundingRateShortPerHour: f30(t.fundingRateShort), borrowingRateLongPerHour: f30(t.borrowingRateLong), borrowingRateShortPerHour: f30(t.borrowingRateShort),55      netRateLongPerHour: f30(t.netRateLong), netRateShortPerHour: f30(t.netRateShort),56      priceImpact: { factorPositive: f30(g.positionImpactFactorPositive), factorNegative: f30(g.positionImpactFactorNegative), exponentPositive: f30(g.positionImpactExponentFactorPositive), exponentNegative: f30(g.positionImpactExponentFactorNegative),57        maxFactorPositive: f30(g.maxPositionImpactFactorPositive), maxFactorNegative: f30(g.maxPositionImpactFactorNegative), maxFactorForLiquidations: f30(g.maxPositionImpactFactorForLiquidations) },58      positionFeeFactor: { balanceImproved: f30(g.positionFeeFactorForBalanceWasImproved), balanceNotImproved: f30(g.positionFeeFactorForBalanceWasNotImproved) },59      minCollateralFactor: f30(g.minCollateralFactor), minCollateralFactorForLiquidation: f30(g.minCollateralFactorForLiquidation),60      minCollateralFactorPerUsdOfOpenInterest: { long: f30(g.minCollateralFactorForOpenInterestLong), short: f30(g.minCollateralFactorForOpenInterestShort) },61    });62  }63  return out;64}6566export function createRouterMock(deps: RouterMockDeps) {67  let state: ReturnType<typeof gmxState> | null = null;68  const ORDER: VenueId[] = ['hyperliquid', 'gmx', 'dydx'];69  const json = (status: number, j: unknown) => ({ status, json: j });7071  function assets() {72    const by = new Map<string, Map<VenueId, string[]>>();73    for (const i of deps.instruments()) { if (i.status === 'halted') continue; const b = i.base.toUpperCase(), v = by.get(b) ?? new Map<VenueId, string[]>(); v.set(i.venue, [...(v.get(i.venue) ?? []), i.id]); by.set(b, v); }74    const list = [...by.entries()].map(([base, m]) => {75      const venues = ORDER.filter((v) => m.has(v)).map((v) => ({ venue: v, name: ROUTER_FACTS[v].name, instruments: m.get(v)!, restriction: deps.restriction(v) }));76      return { base, venues, candidateVenues: venues.filter((v) => v.restriction === null).length };77    }).sort((a, b) => b.candidateVenues - a.candidateVenues || b.venues.length - a.venues.length || a.base.localeCompare(b.base));78    return json(200, { assets: list, errors: [], ts: Date.now() });79  }8081  function route(b: any) {82    const now = Date.now(), { accountIds = [], ...intent } = (b && typeof b === 'object' ? b : {}) as { accountIds?: unknown } & Record<string, unknown>;83    if (!Array.isArray(accountIds) || accountIds.length > 6 || accountIds.some((x) => typeof x !== 'string')) return json(400, { error: 'bad_request', message: 'invalid request' });84    const accounts: MockAccount[] = [];85    for (const id of [...new Set(accountIds as string[])]) { const a = deps.account(id); if (!a) return json(404, { error: 'unknown_account', message: `unknown derivatives account ${id}` }); accounts.push(a); }86    state ??= gmxState(deps.gmxDecimals);87    const want = typeof intent.base === 'string' ? intent.base.toUpperCase() : '';88    const all = deps.markets(want), eth = deps.markets('ETH').find((m) => m.inst.id === 'hyperliquid:ETH');89    // sample chain values (2026-11-11): 0.02 gwei, and the GMX minimum execution fee at that gas price90    const chain: ChainCosts = { gasPriceEth: '0.00000000002', ethUsd: eth?.ticker.mark ?? null, ethUsdSource: 'Hyperliquid ETH mark price', gmxExecutionFeeEth: '0.0000824',91      gmxExecutionFeeNote: 'MOCK: sample of the contracts\' minimum (4 122 171 gas × 0.02 gwei); the real service reads the gas price and the DataStore', ts: now };92    const snaps: VenueSnapshot[] = all.map((m): VenueSnapshot => {93      const v = m.inst.venue, acct = accounts.find((a) => a.venue === v), f = m.ticker.funding;94      const g = v === 'gmx' ? state!.get(m.inst.id) : undefined;95      return {96        venue: v, restriction: deps.restriction(v), wallet: WALLET[v], instrument: m.inst, ticker: { ...m.ticker, ts: now },97        book: m.book ? { ...m.book, ts: now } : null, gmx: g ? { ...g, oracleMin: m.ticker.mark, oracleMax: m.ticker.mark, ts: now } : null,98        // the mock's funding history moves between 0.6 and 1.4 times the current rate99        fundingRange: f !== null && f !== undefined ? { minPerHour: D.str(D.min(D.mul(D.from(f), D.from('0.6')), D.mul(D.from(f), D.from('1.4')))), maxPerHour: D.str(D.max(D.mul(D.from(f), D.from('0.6')), D.mul(D.from(f), D.from('1.4')))), hours: 24 } : null,100        chain, account: acct ? { id: acct.id, label: acct.label, kind: 'paper', mode: acct.mode, equity: acct.equity, freeCollateral: acct.freeCollateral } : null,101      };102    }).sort((x, y) => ORDER.indexOf(x.venue) - ORDER.indexOf(y.venue) || x.instrument.id.localeCompare(y.instrument.id));103    let r: ReturnType<typeof routeOrder>;104    try { r = routeOrder(intent as never, snaps, { now }); } catch { return json(400, { error: 'bad_request', message: 'invalid request' }); }105    const candidates = r.candidates.map((e) => {106      const blockers = e.eligibility.reasons.filter((x) => x.kind === 'blocker').map((x) => x.code);107      return { ...e, risk: {108        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' },109        accounts: accounts.filter((a) => a.venue === e.venue).map((a) => {110          const head = { accountId: a.id, label: a.label, kind: 'paper' as const, mode: a.mode, network: 'mainnet' }, ob = orderBodyFor(e);111          if (!ob) return { ...head, accepted: null, code: null, reason: 'the order could not be sized on this instrument', source: 'not_checked' as const };112          const p = a.preview({ ...ob, triggerPrice: null, tif: ob.type === 'market' ? 'ioc' : 'gtc' }), risk = p.json?.risk as RiskResult | undefined;113          if (p.status !== 200 || !risk) return { ...head, accepted: false, code: String(p.json?.error ?? 'error'), reason: String(p.json?.message ?? ''), source: 'risk_engine' as const };114          return risk.ok ? { ...head, accepted: true, code: null, reason: null, source: 'risk_engine' as const } : { ...head, accepted: false, code: risk.code, reason: risk.reason, source: 'risk_engine' as const };115        }),116      } };117    });118    return json(200, { ...r, candidates, errors: [], chain });119  }120  return { assets, route };121}122
Files main
Menu

Theme