SPB Git

Repository Public

router.test.ts 41.1 KB 407 lines typescript

1// Universal derivatives router (src/router.ts) on the venues' recorded answers: the REAL adapters over a replaying fetch2// (fixtures of 2026-11-10/11), no network. The GMX price-impact formula is checked against the venue's own number for a real3// position; the Hyperliquid book walk against a second, independent computation.4import { describe, expect, it } from 'vitest';5import {6  createDydxAdapter, createGmxAdapter, D, OrderIntentSchema, contractDifferences, estimateVenue, gmxBalanceOi, gmxPriceImpact, orderBodyFor, powDec, rankCandidates, routeOrder, walkBook,7  ARBITRUM_GAS_UNITS, ROUTER_FACTS, ROUTER_MAX_AGE_MS, TERM_KEYS,8  type Book, type ChainCosts, type ExecutionEstimate, type GmxLiquidity, type Instrument, type Ticker, type VenueSnapshot,9} from '../src/index.ts';10import { createHyperliquidAdapter } from '../src/venues/hyperliquid/index.ts';11import { fx as hlFx, replay as hlReplay } from './hyperliquid/helpers.ts';12import { fx, replay, type Route } from './venue-helpers.ts';1314const NOW = Date.parse('2026-11-11T03:05:30Z');15const GMX_ROUTES: Route[] = [16  ['/markets/tickers', fx('gmx', 'v1-markets-tickers')], ['/markets/config', fx('gmx', 'v1-markets-config')], [/\/v1\/markets$/, fx('gmx', 'v1-markets')],17  ['/tokens/info', fx('gmx', 'v1-tokens-info')], ['/pairs', fx('gmx', 'v1-pairs')], ['/positions', fx('gmx', 'v1-positions')],18];19const DYDX_ROUTES: Route[] = [[/\/v4\/perpetualMarkets$/, fx('dydx', 'perpetualMarkets')], ['/orderbooks/perpetualMarket/BTC-USD', fx('dydx', 'orderbook-BTC-USD')]];20const gmx = createGmxAdapter({ fetch: replay(GMX_ROUTES).fetch, now: () => NOW });21const dydx = createDydxAdapter({ fetch: replay(DYDX_ROUTES).fetch, now: () => NOW });22const hlRoute = (n: string) => { const x = hlFx(`mainnet-${n}`); return { status: x.status, body: x.body }; };23const hl = createHyperliquidAdapter({ network: 'mainnet', now: () => NOW, fetch: hlReplay({ meta: hlRoute('meta'), metaAndAssetCtxs: hlRoute('metaAndAssetCtxs'), l2Book: hlRoute('l2Book-BTC') }).fetchImpl });2425const GMX_ACCOUNT = '0x46a3BA57e146A4906bc68759Fb7ad1E74d3B66f6', GMX_BTC = 'BTC/USD [BTC-USDC]';26const CHAIN: ChainCosts = { gasPriceEth: '0.00000000002', ethUsd: '2065.7', ethUsdSource: 'test', gmxExecutionFeeEth: '0.00012', gmxExecutionFeeNote: 'test value', ts: NOW };27const PAPER = { trading: 'paper' as const, note: 'paper trading on this venue; live trading exists and is disabled' };28const NONE = { trading: 'none' as const, note: 'this wallet cannot trade on this venue: read only' };29const d = D.from, n = (x: string) => Number(x);3031async function snapshots(): Promise<{ hl: VenueSnapshot; gmx: VenueSnapshot; dydx: VenueSnapshot; all: VenueSnapshot[] }> {32  const pick = <T extends { id: string }>(l: T[], id: string): T => { const x = l.find((i) => i.id === id); if (!x) throw new Error(`no ${id}`); return x; };33  const tk = (l: Ticker[], id: string): Ticker => ({ ...l.find((t) => t.instrument === id)!, ts: NOW });34  const [hi, ht, hb, gi, gt, gl, di, dt, db] = await Promise.all([hl.instruments(), hl.tickers(), hl.book('BTC'), gmx.instruments(), gmx.tickers(), gmx.liquidity(GMX_BTC), dydx.instruments(), dydx.tickers(), dydx.book('BTC-USD')]);35  const common = { fundingRange: null, chain: CHAIN, account: null };36  const a: VenueSnapshot = { venue: 'hyperliquid', restriction: null, wallet: PAPER, instrument: pick(hi, 'hyperliquid:BTC'), ticker: tk(ht, 'hyperliquid:BTC'), book: { ...hb, ts: NOW }, gmx: null, ...common };37  const b: VenueSnapshot = { venue: 'gmx', restriction: null, wallet: NONE, instrument: pick(gi, `gmx:${GMX_BTC}`), ticker: tk(gt, `gmx:${GMX_BTC}`), book: null, gmx: gl[0]!, ...common };38  const c: VenueSnapshot = { venue: 'dydx', restriction: 'dYdX is not available to you: its terms exclude Canada', wallet: NONE, instrument: pick(di, 'dydx:BTC-USD'), ticker: tk(dt, 'dydx:BTC-USD'), book: { ...db, ts: NOW }, gmx: null, ...common };39  return { hl: a, gmx: b, dydx: c, all: [a, b, c] };40}41// loosely typed on purpose: the tests also feed the schema what it must refuse42const intent = (o: Record<string, unknown> = {}): any => ({ base: 'BTC', side: 'long', notionalUsd: '25000', leverage: '3', type: 'market', horizonHours: '24', ...o });43const costOf = (e: ExecutionEstimate, id: string) => e.costs.find((c) => c.id === id)!;44const codes = (e: ExecutionEstimate, kind?: string) => e.eligibility.reasons.filter((r) => !kind || r.kind === kind).map((r) => r.code);4546describe('intent', () => {47  it('takes exactly one size, a price only on a limit order, and a bounded horizon', () => {48    expect(OrderIntentSchema.parse(intent())).toMatchObject({ reduceOnly: false, price: null, horizonHours: '24' });49    expect(OrderIntentSchema.safeParse(intent({ sizeBase: '0.1' })).success).toBe(false);                       // both sizes50    expect(OrderIntentSchema.safeParse(intent({ notionalUsd: undefined })).success).toBe(false);                  // none51    expect(OrderIntentSchema.safeParse(intent({ type: 'limit' })).success).toBe(false);                           // limit without a price52    expect(OrderIntentSchema.safeParse(intent({ price: '70000' })).success).toBe(false);                          // market with a price53    expect(OrderIntentSchema.safeParse(intent({ type: 'limit', price: '70000' })).success).toBe(true);54    expect(OrderIntentSchema.safeParse(intent({ horizonHours: '8761' })).success).toBe(false);55    expect(OrderIntentSchema.safeParse(intent({ leverage: '0' })).success).toBe(false);56    expect(OrderIntentSchema.safeParse(intent({ notionalUsd: 25000 })).success).toBe(false);                      // a number is not a decimal string57    expect(OrderIntentSchema.safeParse(intent({ venue: 'gmx' })).success).toBe(false);                            // strict: the caller cannot pre-select a venue58  });59});6061describe('exact power', () => {62  it('multiplies integer exponents out exactly and matches known values to 30 decimals otherwise', () => {63    expect(D.str(powDec(d('1.5'), d('2')))).toBe('2.25');64    expect(D.str(powDec(d('12345.678'), d('1')))).toBe('12345.678');65    expect(D.str(powDec(d('2'), d('0.5')))).toBe('1.41421356237309504880168872421');            // √2 = 1.41421356237309504880168872420969807… (30 decimals, trailing zero dropped)66    expect(D.str(powDec(d('10'), d('1.5')))).toBe('31.622776601683793319988935444327');         // 10·√1067    expect(D.str(powDec(d('1000000'), d('1.5')))).toBe('1000000000');68    expect(D.str(powDec(d('0.25'), d('0.5')))).toBe('0.5');69    // e^1 through x = e (60 digits of e): ln then exp must give e back70    expect(D.str(powDec(d('2.718281828459045235360287471352662497757247093699959574966'), d('1.000000000000000000000000000001'))).slice(0, 30)).toBe('2.7182818284590452353602874713');71    expect(() => powDec(d('0'), d('1.5'))).toThrow(); expect(() => powDec(d('2'), d('-1'))).toThrow();72  });73});7475describe('GMX price impact: the documented formula against the venue\'s own figure', () => {76  it('reproduces closePriceImpactDeltaUsd of a real recorded position — with open interest in tokens × price, not with the USD cost basis', async () => {77    const [p] = await gmx.positions(GMX_ACCOUNT), all = await gmx.liquidity();78    const l = all.find((x) => x.instrument === p!.instrument)!;79    expect(p!.instrument).toBe('gmx:LTC/USD [WETH-USDC]'); expect(l.useOpenInterestInTokensForBalance).toBe(true);80    const venue = d(p!.gmx.closePriceImpactDeltaUsd), pi = l.priceImpact;81    const oi = gmxBalanceOi(l);82    // a decrease takes TOKENS off the side: its USD delta is the position's tokens × the current price83    const size = D.mul(d(p!.size), D.div(D.add(d(l.oracleMin), d(l.oracleMax)), d('2'), 30, 'half_even'));84    const args = { sizeDeltaUsd: size, isLong: p!.side === 'long', increase: false, factorPositive: d(pi.factorPositive), factorNegative: d(pi.factorNegative), exponentPositive: d(pi.exponentPositive), exponentNegative: d(pi.exponentNegative) };85    expect(oi.basis).toBe('tokens × oracle mid'); expect(oi.virtual).toBe('unknown');                     // this older fixture set has no /markets/values86    const mine = gmxPriceImpact({ ...args, longOi: oi.long, shortOi: oi.short });87    // closing a short on a short-heavy market reduces the imbalance: positive impact, same side88    expect(mine.balanceWasImproved).toBe(true); expect(mine.crossover).toBe(false);89    const rel = Math.abs(D.toNumber(mine.impactUsd) / D.toNumber(venue) - 1);90    // the two fixtures were recorded seconds apart (the price moved by a few 1e-4): the figures agree to 0.1 %91    expect(D.toNumber(venue)).toBeCloseTo(0.2187, 4); expect(rel).toBeLessThan(0.001);92    // the USD cost-basis open interest the liquidity view used to be limited to gives three times the venue's figure93    const usd = gmxPriceImpact({ ...args, longOi: d(l.openInterestLongUsd), shortOi: d(l.openInterestShortUsd) });94    expect(D.toNumber(usd.impactUsd) / D.toNumber(venue)).toBeGreaterThan(2.5);95    expect(gmxBalanceOi({ ...l, useOpenInterestInTokensForBalance: null }).basis).toBe('USD cost basis');96  });9798  it('reproduces the venue\'s figure on a consistent recorded set — four real positions, one of them decided by the virtual inventory', async () => {99    const R = (name: string) => fx('gmx-router', name);100    const routes = (positions: string): Route[] => [['/markets/tickers', R('v1-markets-tickers')], ['/markets/config', R('v1-markets-config')], ['/markets/values', R('v1-markets-values')],101      [/\/v1\/markets$/, R('v1-markets')], ['/tokens/info', R('v1-tokens-info')], ['/positions', R(positions)]];102    const seen: Record<string, { rel: number; virtual: boolean; sign: number }> = {};103    for (const label of ['eth-short', 'eth-long', 'btc-long', 'btc-short']) {104      const a = createGmxAdapter({ fetch: replay(routes(`v1-positions-${label}`)).fetch, now: () => NOW });105      const [p] = await a.positions('0x0000000000000000000000000000000000000001'), l = (await a.liquidity()).find((x) => x.instrument === p!.instrument)!;106      const oi = gmxBalanceOi(l), pi = l.priceImpact, mid = D.div(D.add(d(l.oracleMin), d(l.oracleMax)), d('2'), 30, 'half_even');107      expect(oi.virtual).toBe(label.startsWith('eth') ? 'known' : 'none');                               // ETH markets share a virtual inventory, BTC/USD [BTC-USDC] has none108      const mine = gmxPriceImpact({ longOi: oi.long, shortOi: oi.short, sizeDeltaUsd: D.mul(d(p!.size), mid), isLong: p!.side === 'long', increase: false, virtualInventoryUsd: oi.virtualUsd,109        factorPositive: d(pi.factorPositive), factorNegative: d(pi.factorNegative), exponentPositive: d(pi.exponentPositive), exponentNegative: d(pi.exponentNegative) });110      const venue = D.toNumber(d(p!.gmx.closePriceImpactDeltaUsd));111      seen[label] = { rel: Math.abs(D.toNumber(mine.impactUsd) / venue - 1), virtual: mine.fromVirtualInventory, sign: Math.sign(venue) };112      // without the virtual inventory the market's own imbalance gives another figure whenever the virtual one decides113      if (mine.fromVirtualInventory) expect(Math.abs(D.toNumber(mine.marketImpactUsd) / venue - 1)).toBeGreaterThan(0.05);114    }115    // positions and market state were read within a few seconds of each other: agreement to 0.2 % at worst116    for (const [label, x] of Object.entries(seen)) expect(x.rel, label).toBeLessThan(0.002);117    expect(Object.values(seen).some((x) => x.virtual)).toBe(true);118  });119120  it('same-side and crossover cases follow the contract code', () => {121    const f = { factorPositive: d('0.00000001'), factorNegative: d('0.00000002'), exponentPositive: d('2'), exponentNegative: d('2') };122    // longs 100, shorts 300: a 100 long reduces the gap 200 → 100: + (200² − 100²) × 1e-8123    expect(D.str(gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: true, increase: true }).impactUsd)).toBe('0.0003');124    // a 100 short widens it 200 → 300: − (300² − 200²) × 2e-8125    expect(D.str(gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: false, increase: true }).impactUsd)).toBe('-0.001');126    // a 500 long crosses the balance point: +200² × 1e-8 − 300² × 2e-8127    const x = gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('500'), isLong: true, increase: true });128    expect(x.crossover).toBe(true); expect(x.balanceWasImproved).toBe(false); expect(D.str(x.impactUsd)).toBe('-0.0014');129    // the positive factor is never above the negative one; a difference under 1 USD contributes nothing130    expect(D.str(gmxPriceImpact({ ...f, factorPositive: d('1'), longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: true, increase: true }).impactUsd)).toBe('0.0006');131    expect(D.str(gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('100.5'), sizeDeltaUsd: d('0.4'), isLong: true, increase: true }).impactUsd)).toBe('0');132    // a decrease larger than the side's open interest floors it at zero133    expect(D.str(gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('1000'), isLong: true, increase: false }).nextDiffUsd)).toBe('300');134    // virtual inventory: only looked at when the market's own impact is negative, and only applied when it is worse.135    // traders net long 1000 across the index (−1000): a 100 short on a market that is short-heavy by 200 → own −0.001; virtual: long 1000, short 0 → 100 short REDUCES the gap → positive → not worse → own figure136    const own = gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: false, increase: true, virtualInventoryUsd: d('-1000') });137    expect([D.str(own.impactUsd), own.fromVirtualInventory]).toEqual(['-0.001', false]);138    // traders net short 1000 (+1000): the same short widens the virtual gap 1000 → 1100: −(1100² − 1000²) × 2e-8 = −0.0042, worse → applied139    const v = gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: false, increase: true, virtualInventoryUsd: d('1000') });140    expect([D.str(v.impactUsd), D.str(v.marketImpactUsd), v.fromVirtualInventory, v.balanceWasImproved]).toEqual(['-0.0042', '-0.001', true, false]);141    // a decrease first shifts both virtual sides by the size (the contracts do it to avoid an underflow), then removes it from the order's side.142    // closing a 100 short on a long-heavy market (300 vs 100) widens its gap 200 → 300: −0.001; virtual: long 1000 + 100, short 0 + 100 − 100: gap 1000 → 1100: −0.0042143    const dec = gmxPriceImpact({ ...f, longOi: d('300'), shortOi: d('100'), sizeDeltaUsd: d('100'), isLong: false, increase: false, virtualInventoryUsd: d('-1000') });144    expect([D.str(dec.impactUsd), dec.fromVirtualInventory]).toEqual(['-0.0042', true]);145    // a positive own impact never consults the virtual inventory146    expect(gmxPriceImpact({ ...f, longOi: d('100'), shortOi: d('300'), sizeDeltaUsd: d('100'), isLong: true, increase: true, virtualInventoryUsd: d('-100000') }).fromVirtualInventory).toBe(false);147  });148});149150describe('order book walk', () => {151  const asks: [string, string][] = [['100', '1'], ['101', '2'], ['103', '5']];152  it('fills level by level, reports the worst price, and stops where the snapshot or the limit stops', () => {153    const w = walkBook(asks, d('2.5'), 'buy', null);154    expect([D.str(w.filled), D.str(w.cost), D.str(w.avg!), D.str(w.worst!), w.levels, w.stoppedBy]).toEqual(['2.5', '251.5', '100.6', '101', 2, 'filled']);155    const end = walkBook(asks, d('10'), 'buy', null);156    expect([D.str(end.filled), D.str(end.remainder), end.stoppedBy, D.str(end.sideTotal)]).toEqual(['8', '2', 'book_snapshot_ends', '8']);157    const lim = walkBook(asks, d('10'), 'buy', d('101'));158    expect([D.str(lim.filled), D.str(lim.remainder), lim.stoppedBy]).toEqual(['3', '7', 'limit_price']);159    expect(walkBook([['99', '1'], ['98', '1']], d('2'), 'sell', d('98.5')).stoppedBy).toBe('limit_price');160    expect(walkBook(asks, d('1'), 'buy', d('99')).avg).toBeNull();161  });162163  it('agrees with a second, independent computation on the recorded Hyperliquid book (floating point, level by level)', async () => {164    const s = await snapshots();165    for (const usd of ['1000', '25000', '500000']) for (const side of ['long', 'short'] as const) {166      const e = estimateVenue(s.hl, { intent: OrderIntentSchema.parse(intent({ notionalUsd: usd, side })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });167      const levels = side === 'long' ? s.hl.book!.asks : s.hl.book!.bids;168      let left = n(e.order!.sizeBase), cost = 0, worst = 0;169      for (const [p, q] of levels) { if (left <= 1e-12) break; const take = Math.min(left, n(q)); cost += take * n(p); left -= take; worst = n(p); }170      const filled = n(e.order!.sizeBase) - left, mid = (n(s.hl.book!.bids[0]![0]) + n(s.hl.book!.asks[0]![0])) / 2;171      expect(n(e.fill!.filledBase)).toBeCloseTo(filled, 9);172      if (filled > 0) {173        expect(n(e.fill!.avgPrice!)).toBeCloseTo(cost / filled, 6); expect(n(e.fill!.worstPrice!)).toBe(worst);174        expect(n(e.fill!.slippageBps!)).toBeCloseTo(((side === 'long' ? 1 : -1) * (cost / filled - mid) / mid) * 1e4, 4);175      }176      expect(n(e.fill!.unfilledBase)).toBeCloseTo(Math.max(0, left), 9);177    }178  });179});180181describe('estimate on Hyperliquid (order book)', () => {182  it('itemises spread, fee and funding with their inputs and sources; margin and liquidation are estimates', async () => {183    const s = await snapshots();184    const e = estimateVenue(s.hl, { intent: OrderIntentSchema.parse(intent()), now: NOW, maxAge: ROUTER_MAX_AGE_MS });185    expect(e.eligibility).toMatchObject({ eligible: true, executableNow: false });                      // no account with collateral was given186    expect(codes(e, 'blocker')).toEqual([]); expect(codes(e, 'setup')).toEqual(['no_collateral_account']); expect(codes(e, 'note')).toContain('paper_only');187    const mid = (n(s.hl.book!.bids[0]![0]) + n(s.hl.book!.asks[0]![0])) / 2;188    expect(e.order).toMatchObject({ side: 'buy', sizeUnit: 'base', type: 'market' });189    expect(D.isMultiple(d(e.order!.sizeBase), d(s.hl.instrument.lotSize))).toBe(true);190    expect(n(e.order!.sizeBase) * mid).toBeLessThanOrEqual(25000); expect(n(e.order!.sizeBase) * mid).toBeGreaterThan(25000 - mid * 0.00001);191    expect(e.order!.rounding).toContain('lot');192    expect(e.fill).toMatchObject({ method: 'order_book_walk', unfilledBase: '0', unfilledReason: 'none', restingBase: '0', referenceSource: 'venue', source: 'estimate' });193    expect(n(e.fill!.depthConsumedPct!)).toBeGreaterThan(0); expect(n(e.fill!.depthConsumedPct!)).toBeLessThan(100);194    expect(e.costs.map((c) => c.id)).toEqual(['spread_slippage', 'trading_fee', 'funding', 'collateral_transfer']);195    expect(e.costs.every((c) => c.source === 'estimate' && c.basis.length > 20 && c.uncertainty.length > 3)).toBe(true);196    // fee = base-tier taker rate (a documented constant) × the filled notional, exactly197    const fee = costOf(e, 'trading_fee');198    expect(fee.usd).toBe(D.str(D.div(D.mul(d(e.fill!.filledUsd), d('0.00045')), D.one, 6, 'half_even')));199    expect(fee.inputs.find((i) => i.name === 'taker rate')).toEqual({ name: 'taker rate', value: '0.00045', source: 'constant' });200    expect(n(fee.bps!)).toBeCloseTo(4.5, 1);201    // funding = size × oracle price × the venue's hourly rate × 24, paid by the long; the basis says the rate changes202    const f = costOf(e, 'funding'), rate = s.hl.ticker.funding!;203    expect(n(f.usd!)).toBeCloseTo(n(e.order!.sizeBase) * n(s.hl.ticker.index!) * n(rate) * 24, 5);204    expect(f.inputs[0]).toMatchObject({ value: D.str(d(rate)), source: 'venue' }); expect(f.basis).toMatch(/RATE CHANGES/); expect(f.timing).toBe('over_horizon');205    expect(f.uncertaintyUsd).toBe(D.str(D.abs(d(f.usd!))));                                           // no history supplied: ±100 %206    // a short receives what the long pays207    const sh = estimateVenue(s.hl, { intent: OrderIntentSchema.parse(intent({ side: 'short' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });208    expect(n(costOf(sh, 'funding').usd!)).toBeCloseTo(-n(f.usd!), 2);209    // collateral: a USDC transfer on Arbitrum at the snapshot's gas price, with the venue's deposit facts as constants210    const col = costOf(e, 'collateral_transfer');211    expect(n(col.usd!)).toBeCloseTo(n(ARBITRUM_GAS_UNITS.erc20Transfer) * 0.00000000002 * 2065.7, 6);212    expect(col.inputs[0]!.value).toMatch(/Minimum 5 USDC/); expect(col.timing).toBe('before_trading');213    // margin: notional / leverage; maintenance = half the initial margin at the venue's maximum leverage214    const maxLev = n(s.hl.instrument.maxLeverage);215    expect(n(e.margin!.initialMarginUsd)).toBeCloseTo(n(e.margin!.notionalUsd) / 3, 4); expect(n(e.margin!.maintenanceFraction!)).toBeCloseTo(1 / (2 * maxLev), 9);216    expect(e.margin!.liquidationPrice.source).toBe('estimate'); expect(e.margin!.maintenanceSource).toBe('constant');217    const entry = n(e.margin!.notionalUsd) / n(e.order!.sizeBase), liq = n(e.margin!.liquidationPrice.value!);218    expect(liq).toBeCloseTo(entry * (1 - 1 / 3) / (1 - 1 / (2 * maxLev)), 3);219    expect(n(e.margin!.liquidationDistanceBps!)).toBeCloseTo(((entry - liq) / entry) * 1e4, 2);220    // all-in = the sum of the known components; exit is separate information221    const sum = e.costs.reduce((a, c) => a + n(c.usd!), 0);222    expect(n(e.allIn!.usd)).toBeCloseTo(sum, 5); expect(e.allIn).toMatchObject({ complete: true, unknown: [], horizonHours: '24' });223    expect(n(e.allIn!.bps)).toBeCloseTo((sum / n(e.order!.sizeUsd)) * 1e4, 2);224    expect(e.exit!.parts.map((p) => p.id)).toEqual(['spread_slippage', 'trading_fee']); expect(e.exit!.note).toMatch(/information only/);225    expect(orderBodyFor(e)).toEqual({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'market', size: e.order!.sizeBase, price: null, reduceOnly: false, leverage: '3', slippageBps: 50 });226  });227228  it('an account with enough collateral makes the order executable (as paper) and the collateral cost zero; a funding history narrows the uncertainty', async () => {229    const s = await snapshots();230    const acct = { id: 'a1', label: 'Sim', kind: 'paper' as const, mode: 'PAPER', equity: '50000', freeCollateral: '50000' };231    const e = estimateVenue({ ...s.hl, account: acct, fundingRange: { minPerHour: '0.00001', maxPerHour: '0.000014', hours: 24 } }, { intent: OrderIntentSchema.parse(intent()), now: NOW, maxAge: ROUTER_MAX_AGE_MS });232    expect(e.eligibility).toMatchObject({ eligible: true, executableNow: true, executableAs: 'paper' });233    expect(costOf(e, 'collateral_transfer')).toMatchObject({ usd: '0' }); expect(costOf(e, 'collateral_transfer').basis).toMatch(/SIMULATED/);234    expect(n(costOf(e, 'funding').uncertaintyUsd)).toBeCloseTo(n(e.order!.sizeBase) * n(s.hl.ticker.index!) * 24 * 0.000002, 4);235    const poor = estimateVenue({ ...s.hl, account: { ...acct, freeCollateral: '100' } }, { intent: OrderIntentSchema.parse(intent()), now: NOW, maxAge: ROUTER_MAX_AGE_MS });236    expect(codes(poor, 'setup')).toEqual(['insufficient_collateral_there']); expect(poor.eligibility.executableNow).toBe(false);237  });238239  it('refuses with a named reason: lot, minimum, leverage, halted, reduce-only instrument, stale data, a book that ends', async () => {240    const s = await snapshots(), run = (snap: VenueSnapshot, o: Record<string, unknown>, now = NOW) => estimateVenue(snap, { intent: OrderIntentSchema.parse(intent(o)), now, maxAge: ROUTER_MAX_AGE_MS });241    expect(codes(run(s.hl, { notionalUsd: '0.5' }), 'blocker')).toEqual(['size_rounds_to_zero']);242    expect(codes(run(s.hl, { notionalUsd: '5' }), 'blocker')).toEqual(['below_min_notional']);243    expect(codes(run(s.hl, { leverage: '41' }), 'blocker')).toEqual(['leverage_above_venue_max']);244    expect(codes(run(s.hl, { leverage: '2.5' }), 'blocker')).toEqual(['leverage_not_integer']);245    expect(codes(run({ ...s.hl, instrument: { ...s.hl.instrument, status: 'halted' } }, {}), 'blocker')).toEqual(['instrument_halted']);246    expect(codes(run({ ...s.hl, instrument: { ...s.hl.instrument, status: 'reduce_only' } }, {}), 'blocker')).toEqual(['instrument_reduce_only']);247    expect(codes(run({ ...s.hl, instrument: { ...s.hl.instrument, status: 'reduce_only' } }, { reduceOnly: true }), 'blocker')).toEqual([]);248    // stale data: no fill, no cost, no total — the router does not estimate on it249    const old = run(s.hl, {}, NOW + ROUTER_MAX_AGE_MS.ticker + 1);250    expect(codes(old, 'blocker')).toEqual(['stale_data']); expect(old).toMatchObject({ fill: null, costs: [], allIn: null, margin: null });251    const oldBook = run({ ...s.hl, book: { ...s.hl.book!, ts: NOW - ROUTER_MAX_AGE_MS.book - 1 } }, {});252    expect(codes(oldBook, 'blocker')).toEqual(['stale_data']); expect(oldBook.fill).toBeNull();253    expect(codes(run({ ...s.hl, book: null }, {}), 'blocker')).toEqual(['no_depth_data']);254    // a size beyond the snapshot: the remainder is named, not extrapolated, and the instrument is not ranked255    const thin: Book = { instrument: 'hyperliquid:BTC', bids: [['73000', '0.1']], asks: [['73010', '0.1'], ['73020', '0.1']], ts: NOW };256    const big = run({ ...s.hl, book: thin }, { notionalUsd: '50000' });257    expect(codes(big, 'blocker')).toEqual(['book_snapshot_ends']);258    expect(big.fill).toMatchObject({ filledBase: '0.2', unfilledReason: 'book_snapshot_ends', levelsConsumed: 2, snapshotLevels: 2, depthConsumedPct: '100' });259    expect(n(big.fill!.unfilledBase)).toBeGreaterThan(0.4); expect(big.fill!.notes[0]).toMatch(/snapshot ends/);260    expect(big.eligibility.reasons.find((r) => r.code === 'book_snapshot_ends')!.message).toMatch(/not extrapolated/);261    expect(big.allIn).toMatchObject({ complete: false }); expect(big.allIn!.note).toMatch(/^PARTIAL/);262    expect(rankCandidates([big]).rows).toEqual([]);263  });264265  it('a limit order fills what is marketable and says what would rest; a reduce-only order needs no margin and has no carry', async () => {266    const s = await snapshots(), book: Book = { instrument: 'hyperliquid:BTC', bids: [['73000', '1']], asks: [['73010', '0.1'], ['73020', '5']], ts: NOW };267    const e = estimateVenue({ ...s.hl, book }, { intent: OrderIntentSchema.parse(intent({ notionalUsd: undefined, sizeBase: '0.3', type: 'limit', price: '73010' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });268    expect(e.fill).toMatchObject({ filledBase: '0.1', restingBase: '0.2', unfilledBase: '0', unfilledReason: 'limit_price' });269    expect(codes(e, 'note')).toContain('limit_rests'); expect(e.eligibility.eligible).toBe(true);270    // 0.1 taker at 73010 + 0.2 maker at 73010271    expect(costOf(e, 'trading_fee').usd).toBe(D.str(D.add(D.mul(d('7301'), d('0.00045')), D.mul(d('14602'), d('0.00015')))));272    expect(costOf(e, 'spread_slippage').usd).toBe('1.5');                                              // 0.3 × (73010 − 73005)273    const rest = estimateVenue({ ...s.hl, book }, { intent: OrderIntentSchema.parse(intent({ notionalUsd: undefined, sizeBase: '0.3', type: 'limit', price: '72000' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });274    expect(rest.fill).toMatchObject({ filledBase: '0', restingBase: '0.3', unfilledReason: 'not_marketable', avgPrice: null });275    expect(n(costOf(rest, 'spread_slippage').usd!)).toBeLessThan(0);                                   // resting below the mid: a price improvement IF it fills276    expect(codes(estimateVenue({ ...s.hl, book }, { intent: OrderIntentSchema.parse(intent({ notionalUsd: undefined, sizeBase: '0.3', type: 'limit', price: '73010.55' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS }), 'blocker')).toEqual(['price_not_on_tick']);277    const ro = estimateVenue(s.hl, { intent: OrderIntentSchema.parse(intent({ reduceOnly: true, leverage: '100' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });278    expect(ro.order!.side).toBe('sell'); expect(ro.margin).toBeNull(); expect(ro.costs.map((c) => c.id)).toEqual(['spread_slippage', 'trading_fee']); expect(codes(ro, 'blocker')).toEqual([]);279  });280});281282describe('estimate on GMX (no order book)', () => {283  it('prices from the oracle range, the impact formula inside its caps, the position fee tier, execution fee, gas, funding and borrowing', async () => {284    const s = await snapshots(), l = s.gmx.gmx!;285    const e = estimateVenue(s.gmx, { intent: OrderIntentSchema.parse(intent({ notionalUsd: '500000' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });286    expect(e.eligibility).toMatchObject({ eligible: true, executableNow: false, executableAs: null });287    expect(codes(e, 'setup')).toEqual(['wallet_cannot_trade_here', 'no_collateral_account']); expect(codes(e, 'note')).toEqual(expect.arrayContaining(['two_step_execution', 'gmx_interface_leverage']));288    expect(e.order).toMatchObject({ sizeUnit: 'usd', sizeUsd: '500000', side: 'buy' });289    expect(e.fill).toMatchObject({ method: 'gmx_oracle_impact', levelsConsumed: null, unfilledBase: '0' });290    expect(e.fill!.gmx).toMatchObject({ oracleMin: l.oracleMin, oracleMax: l.oracleMax, openInterestBasis: 'tokens × oracle mid', capNegativeUsd: '2500', capPositiveUsd: '2000', capped: false, virtualInventory: 'none', fromVirtualInventory: false });291    // BTC's published oracle range has min = max in this snapshot: no spread, and that is said; a range with a spread is charged on the side the order executes at292    expect(costOf(e, 'spread_slippage').usd).toBe('0'); expect(costOf(e, 'spread_slippage').basis).toMatch(/min = max/);293    const mid = n(l.oracleMin);294    const wide = estimateVenue({ ...s.gmx, gmx: { ...l, oracleMin: D.str(d(String(mid - 4))), oracleMax: D.str(d(String(mid + 4))) } }, { intent: OrderIntentSchema.parse(intent({ notionalUsd: '500000' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });295    expect(n(costOf(wide, 'spread_slippage').usd!)).toBeCloseTo((500000 / (mid + 4)) * 4, 2); expect(n(wide.fill!.avgPrice!)).toBeCloseTo(mid + 4, 6); expect(costOf(wide, 'spread_slippage').basis).not.toMatch(/min = max/);296    // longs are the smaller side by more than 500 000 USD: a long reduces the imbalance → positive impact, lower fee factor297    const oi = gmxBalanceOi(l), gap = D.toNumber(D.sub(oi.short, oi.long));298    expect(gap).toBeGreaterThan(500000);299    expect(e.fill!.gmx).toMatchObject({ balanceWasImproved: true, crossover: false });300    const f = n(l.priceImpact.factorPositive), x = n(l.priceImpact.exponentPositive);301    expect(n(e.fill!.gmx!.impactUsd)).toBeCloseTo(f * (gap ** x - (gap - 500000) ** x), 3);302    expect(n(costOf(e, 'price_impact').usd!)).toBeCloseTo(-n(e.fill!.gmx!.impactUsd), 6); expect(costOf(e, 'price_impact').timing).toBe('at_close');303    expect(costOf(e, 'price_impact').basis).toMatch(/impact pool balance is not modelled/); expect(costOf(e, 'price_impact').basis).toMatch(/positive impact is a credit RECORDED at the open and SETTLED at the close/);304    expect(costOf(e, 'price_impact').uncertaintyUsd).toBe(D.str(D.abs(d(costOf(e, 'price_impact').usd!))));     // ±100 %: it is settled at the close305    // an immediate close under the same snapshot takes the credit back: the round trip costs more than the entry figure says306    expect(e.exit!.parts.map((p) => p.id)).toEqual(['price_impact', 'spread_slippage', 'trading_fee', 'execution_fee', 'network_gas']);307    expect(n(e.exit!.parts[0]!.usd)).toBeGreaterThan(-n(costOf(e, 'price_impact').usd!)); expect(n(e.exit!.roundTripUsd)).toBeCloseTo(n(e.allIn!.usd) + n(e.exit!.usd), 4);308    expect(costOf(e, 'trading_fee')).toMatchObject({ usd: '200', bps: '4' });                            // 0.04 % of 500 000309    expect(costOf(e, 'trading_fee').inputs[0]).toMatchObject({ value: '0.0004', source: 'venue' });310    expect(n(costOf(e, 'execution_fee').usd!)).toBeCloseTo(0.00012 * 2065.7, 6);311    expect(n(costOf(e, 'network_gas').usd!)).toBeCloseTo(n(ARBITRUM_GAS_UNITS.gmxCreateOrder) * 0.00000000002 * 2065.7, 6);312    // venue sign: the long side's funding rate is negative (it pays) → a cost; longs pay no borrowing today313    expect(n(l.fundingRateLongPerHour)).toBeLessThan(0);314    expect(n(costOf(e, 'funding').usd!)).toBeCloseTo(-n(l.fundingRateLongPerHour) * 500000 * 24, 4);315    expect(costOf(e, 'borrow').usd).toBe('0'); expect(n(costOf(e, 'borrow').uncertaintyUsd)).toBeCloseTo(n(l.borrowingRateShortPerHour) * 500000 * 24, 4);316    expect(costOf(e, 'collateral_transfer').usd).toBeNull();                                           // unknown: said, not guessed317    expect(e.allIn).toMatchObject({ complete: false, unknown: ['collateral_transfer'] }); expect(e.allIn!.note).toMatch(/NOT included because unknown: collateral_transfer/);318    // a short widens the imbalance: negative impact, higher fee factor, and it pays borrowing319    const sh = estimateVenue(s.gmx, { intent: OrderIntentSchema.parse(intent({ notionalUsd: '500000', side: 'short' })), now: NOW, maxAge: ROUTER_MAX_AGE_MS });320    expect(sh.fill!.gmx!.balanceWasImproved).toBe(false); expect(n(costOf(sh, 'price_impact').usd!)).toBeGreaterThan(0);321    expect(costOf(sh, 'trading_fee')).toMatchObject({ usd: '300', bps: '6' }); expect(n(costOf(sh, 'borrow').usd!)).toBeGreaterThan(0);322    expect(n(costOf(sh, 'funding').usd!)).toBeLessThan(0);                                             // shorts receive funding today323    // margin: liquidation from the market's own minimum collateral factor324    expect(e.margin).toMatchObject({ maintenanceFraction: '0.005', maintenanceSource: 'venue' }); expect(e.margin!.liquidationPrice.method).toMatch(/opening fee/);325    expect(n(e.margin!.liquidationPrice.value!)).toBeLessThan(n(l.oracleMin));326    expect(orderBodyFor(e)).toBeNull();                                                                // a USD-sized GMX estimate is not an order for a book account327    // without a chain snapshot the two chain costs are unknown and named328    const dry = estimateVenue({ ...s.gmx, chain: null }, { intent: OrderIntentSchema.parse(intent()), now: NOW, maxAge: ROUTER_MAX_AGE_MS });329    expect(dry.allIn!.unknown).toEqual(['execution_fee', 'network_gas', 'collateral_transfer']);330  });331332  it('caps the impact at the market\'s maximum factor, and refuses above capacity, above the open-interest cap and above the leverage the market allows', async () => {333    const s = await snapshots(), l = s.gmx.gmx!, run = (g: GmxLiquidity, o: Record<string, unknown>) => estimateVenue({ ...s.gmx, gmx: g }, { intent: OrderIntentSchema.parse(intent(o)), now: NOW, maxAge: ROUTER_MAX_AGE_MS });334    const steep = run({ ...l, priceImpact: { ...l.priceImpact, factorNegative: '0.001' } }, { notionalUsd: '500000', side: 'short' });335    expect(steep.fill!.gmx).toMatchObject({ capped: true, impactCappedUsd: '-2500' }); expect(costOf(steep, 'price_impact').usd).toBe('2500'); expect(costOf(steep, 'price_impact').basis).toMatch(/CAPPED/);336    expect(codes(run(l, { notionalUsd: '90000000' }), 'blocker')).toEqual(['above_capacity']);337    expect(codes(run({ ...l, maxOpenInterestLongUsd: '9700000' }, { notionalUsd: '500000' }), 'blocker')).toEqual(['above_open_interest_cap']);338    expect(codes(run(l, { leverage: '201' }), 'blocker')).toEqual(['leverage_above_venue_max']);339    // the minimum collateral factor grows with open interest: 6e-11 per USD × 100 M = 0.6 % → 166× at most340    expect(codes(run({ ...l, openInterestLongUsd: '100000000', maxOpenInterestLongUsd: '200000000' }, { leverage: '180' }), 'blocker')).toEqual(['leverage_above_venue_max']);341    expect(codes(run(l, { notionalUsd: '0.5' }), 'blocker')).toEqual(['below_min_notional']);342    expect(codes(run({ ...l, ts: NOW - ROUTER_MAX_AGE_MS.liquidity - 1 }, {}), 'blocker')).toEqual(['stale_data']);343    // a market with a virtual inventory whose value could not be read: a negative impact is widened to the cap, and that is said344    const blind = run({ ...l, virtualInventory: { applies: true, tokens: null, usd: null } }, { notionalUsd: '500000', side: 'short' });345    expect(blind.fill!.gmx!.virtualInventory).toBe('unknown'); expect(costOf(blind, 'price_impact').basis).toMatch(/VIRTUAL INVENTORY UNKNOWN/);346    expect(n(costOf(blind, 'price_impact').uncertaintyUsd)).toBeCloseTo(2500 - n(costOf(blind, 'price_impact').usd!), 4);347    // …and when it is known and worse than the market's own imbalance, it gives the figure348    const worse = run({ ...l, virtualInventory: { applies: true, tokens: '200', usd: '0' } }, { notionalUsd: '500000', side: 'short' });349    expect(worse.fill!.gmx).toMatchObject({ virtualInventory: 'known', fromVirtualInventory: true }); expect(n(costOf(worse, 'price_impact').usd!)).toBeGreaterThan(n(costOf(blind, 'price_impact').usd!));350  });351});352353describe('the whole comparison', () => {354  it('estimates every instrument, never ranks a restricted venue, selects nothing, and refuses to call two contracts the same', async () => {355    const s = await snapshots();356    const r = routeOrder(intent(), s.all, { now: NOW });357    expect(r.candidates.map((c) => c.venue)).toEqual(['hyperliquid', 'gmx', 'dydx']); expect(r.source).toBe('estimate'); expect(r.ts).toBe(NOW);358    const dy = r.candidates[2]!;359    expect(dy.eligibility.eligible).toBe(false); expect(codes(dy, 'blocker')).toEqual(['venue_restricted']); expect(dy.eligibility.reasons[0]!.message).toMatch(/Canada/);360    expect(dy.fill).not.toBeNull();                                                                    // public data, shown for information361    expect(costOf(dy, 'collateral_transfer').basis).toMatch(/not available to the owner/);362    expect(r.ranking.selected).toBeNull(); expect(r.ranking.note).toMatch(/selects nothing/); expect(r.ranking.note).toMatch(/not "best"/);363    expect(r.ranking.rows.map((x) => x.venue).sort()).toEqual(['gmx', 'hyperliquid']);364    expect(r.ranking.excluded).toEqual([{ instrument: 'dydx:BTC-USD', venue: 'dydx', reasons: ['venue_restricted'] }]);365    const [first, second] = r.ranking.rows;366    expect(first!.rank).toBe(1); expect(second!.rank).toBe(2); expect(D.lte(d(first!.allInUsd), d(second!.allInUsd))).toBe(true);367    expect(r.ranking.rows.every((x) => x.executableNow === false)).toBe(true);368    expect(r.differences.interchangeable).toBe(false); expect(r.differences.statement).toMatch(/3 different contracts on BTC\. None of them is the same contract/);369    const fields = r.differences.fields.map((f) => f.field);370    expect(fields).toEqual(expect.arrayContaining(['collateral', 'settlement', 'markPrice', 'fundingInterval', 'maxLeverage', 'structure', 'execution', 'lot', 'maintenance']));371    expect(r.differences.fields.find((f) => f.field === 'fundingInterval')!.values.map((v) => v.value)).toEqual(['every 1 h', 'continuous (per second), quoted per 1 h', 'every 1 h']);372    expect(r.differences.never.length).toBeGreaterThanOrEqual(4);373    expect(r.warnings.join(' ')).toMatch(/Simulation only/); expect(r.warnings.join(' ')).toMatch(/rates change/); expect(r.warnings.join(' ')).toMatch(/NOT in the totals/); expect(r.warnings.join(' ')).toMatch(/never ranked/);374    expect(TERM_KEYS.every((k) => r.candidates.every((c) => c.terms[k].value.length > 0))).toBe(true);375    // another base: nothing to compare, said so376    const none = routeOrder(intent({ base: 'ZZZ' }), s.all, { now: NOW });377    expect(none.candidates).toEqual([]); expect(none.ranking.rows).toEqual([]); expect(none.warnings.join(' ')).toMatch(/No venue lists a perpetual on ZZZ/);378    expect(() => routeOrder({ ...intent(), leverage: 'abc' }, s.all, { now: NOW })).toThrow();379  });380381  it('reports ties and near-ties instead of an order the estimates do not establish', () => {382    const mk = (instrument: string, usd: string, unc: string, eligible = true, trip: string | null = null): ExecutionEstimate => ({383      venue: 'hyperliquid', venueName: 'x', instrument, symbol: instrument, eligibility: { eligible, executableNow: false, executableAs: null, reasons: eligible ? [] : [{ code: 'stale_data', kind: 'blocker', message: '' }] },384      order: null, fill: null, costs: [], margin: null, terms: {} as never, data: { tickerAgeMs: 0, depthAgeMs: 0 }, source: 'estimate', ts: 0,385      allIn: { usd, bps: '0', uncertaintyUsd: unc, included: [], unknown: [], horizonHours: '24', complete: true, note: '' },386      exit: trip === null ? null : { usd: '0', bps: '0', roundTripUsd: trip, roundTripBps: '0', parts: [], note: '' } });387    const r = rankCandidates([mk('c', '20', '1'), mk('a', '10', '0.5'), mk('b', '10', '0.5'), mk('d', '10.8', '0.4'), mk('e', '5', '0', false)]);388    expect(r.orderChangesWithExit).toBe(false); expect(r.rows.every((x) => x.roundTripUsd === null && x.roundTripRank === null)).toBe(true);389    // the round trip can order the instruments differently: said, never used to rank390    const t = rankCandidates([mk('x', '10', '0', true, '30'), mk('y', '12', '0', true, '20')]);391    expect(t.rows.map((x) => [x.instrument, x.rank, x.roundTripRank])).toEqual([['x', 1, 2], ['y', 2, 1]]); expect(t.orderChangesWithExit).toBe(true);392    expect(rankCandidates([mk('x', '10', '0', true, '20'), mk('y', '12', '0', true, '30')]).orderChangesWithExit).toBe(false);393    expect(r.rows.map((x) => [x.instrument, x.rank])).toEqual([['a', 1], ['b', 1], ['d', 3], ['c', 4]]);394    expect(r.rows[0]).toMatchObject({ tiedWith: ['b'], nearTieWith: ['d'] });                           // 10.8 − 10 ≤ 0.5 + 0.4395    expect(r.rows[2]).toMatchObject({ tiedWith: [], nearTieWith: ['a', 'b'] });396    expect(r.rows[3]).toMatchObject({ tiedWith: [], nearTieWith: [] });397    expect(r.excluded).toEqual([{ instrument: 'e', venue: 'hyperliquid', reasons: ['stale_data'] }]); expect(r.selected).toBeNull();398    expect(contractDifferences('BTC', []).interchangeable).toBe(false);399    expect(contractDifferences('BTC', [mk('a', '1', '0')].map((e) => ({ ...e, terms: Object.fromEntries(TERM_KEYS.map((k) => [k, { value: 'v', source: 'venue' }])) as never }))).statement).toMatch(/not the same contract as a BTC perpetual anywhere else/);400  });401402  it('every documented constant names its page', () => {403    for (const f of Object.values(ROUTER_FACTS)) { expect(f.feeSource).toMatch(/https:\/\//); expect(f.maintenanceText).toMatch(/https:\/\//); expect(f.collateral.source.length).toBeGreaterThan(10); }404    const inst = null as unknown as Instrument; void inst;405  });406});407
Files main
Menu

Theme