SPB Git

Repository Public

derivatives-router.test.ts 20.8 KB 235 lines typescript

1// The router routes (docs/v4/ROUTER.md) through the real server on fixture-backed venue adapters: POST /derivatives/route and2// GET /derivatives/route/assets. Simulation only — the tests also check that nothing reaches a trading endpoint, that no dYdX3// account endpoint is called, and that nothing is stored. Database spw_deriv_router_test (created here if missing). No network.4import { createHash, randomBytes } from 'node:crypto';5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import type { ChainCosts } from '@spw/derivatives';7import { BTC, Clock, DYDX_BTC, GMX_BTC, HL_ACCOUNT, fixtureVenues, setMarket, useDatabase, type Venues } from './derivatives-helpers.ts';89const ORIGIN = 'https://wallet.test', P = '/api/v1/derivatives';10let app: any, sql: any, cookie = '', clock: Clock, v: Venues, d: import('../src/derivatives/index.ts').Derivatives;11const LIVE_ID = '11111111-1111-4111-8111-111111111111', TESTNET_ID = '22222222-2222-4222-8222-222222222222';12const livePreviews: unknown[] = [];13let rpcCalls = 0;1415async function api(method: 'GET' | 'POST', path: string, body?: unknown, opts: { cookie?: string | null; csrf?: boolean } = {}) {16  const headers: Record<string, string> = {};17  const c = opts.cookie === undefined ? cookie : opts.cookie;18  if (c) headers.cookie = c;19  if (method !== 'GET') { headers.origin = ORIGIN; if (opts.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }20  const r = await app.inject({ method, url: path, headers, payload: body === undefined ? (method === 'GET' ? undefined : '{}') : JSON.stringify(body) });21  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })() };22}23const intent = (o: Record<string, unknown> = {}) => ({ base: 'BTC', side: 'long', notionalUsd: '25000', leverage: '3', type: 'market', horizonHours: '24', ...o });24const route = (o: Record<string, unknown> = {}) => api('POST', P + '/route', intent(o));25const cand = (j: any, id: string) => j.candidates.find((c: any) => c.instrument === id);26const codes = (c: any, kind = 'blocker') => c.eligibility.reasons.filter((r: any) => r.kind === kind).map((r: any) => r.code);27const cost = (c: any, id: string) => c.costs.find((x: any) => x.id === id);2829beforeAll(async () => {30  await useDatabase('deriv_router');31  const db = await import('../src/db.ts');32  sql = db.sql;33  await db.migrate();34  await sql`truncate deriv_paper_accounts cascade`;35  const token = randomBytes(32).toString('base64url');36  await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;37  cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;38  const mod = await import('../src/derivatives/index.ts'), { UserError } = await import('../src/config.ts');39  clock = new Clock(); v = fixtureVenues(clock);40  const chain = { read: async (eth: { value: string; source: string } | null): Promise<ChainCosts> => { rpcCalls += 1; return { gasPriceEth: '0.00000000002', ethUsd: eth?.value ?? null, ethUsdSource: eth?.source ?? 'none', gmxExecutionFeeEth: '0.00008', gmxExecutionFeeNote: 'fixture', ts: clock.now() }; } };41  // a venue account that was never authorized (its preview refuses), and one on testnet42  const live = {43    list: async () => [{ id: LIVE_ID, venue: 'hyperliquid', network: 'mainnet', label: 'HL réel', mode: 'READ_ONLY', account: HL_ACCOUNT }, { id: TESTNET_ID, venue: 'hyperliquid', network: 'testnet', label: 'HL testnet', mode: 'MANUAL_LIVE', account: HL_ACCOUNT }],44    preview: async (id: string, body: unknown) => { livePreviews.push({ id, body }); throw new UserError('mode_read_only', 'this venue account is read-only: live trading has not been authorized with the owner\'s passkey', 422); },45  };46  d = mod.createDerivatives({ registry: mod.createRegistry(v.adapters, clock.now), now: clock.now, chain, live });47  app = await (await import('../src/server.ts')).buildServer({ derivatives: d });48});49afterAll(async () => { app?.server.closeAllConnections?.(); await app?.close(); await sql?.end(); });5051describe('access and validation', () => {52  it('both routes need the owner session; the POST needs the CSRF header; a malformed intent is a 400', async () => {53    expect((await api('GET', P + '/route/assets', undefined, { cookie: null })).status).toBe(401);54    expect((await api('POST', P + '/route', intent(), { cookie: null })).status).toBe(401);55    expect((await api('POST', P + '/route', intent(), { csrf: false })).status).toBe(403);56    expect(v.hl.calls).toEqual({}); expect(rpcCalls).toBe(0);                                             // nothing was read for a refused request57    for (const bad of [{}, intent({ notionalUsd: undefined }), intent({ sizeBase: '0.1' }), intent({ leverage: '0' }), intent({ notionalUsd: 25000 }), intent({ type: 'limit' }), intent({ price: '70000' }),58      intent({ base: 'B/TC' }), intent({ side: 'buy' }), intent({ horizonHours: '9000' }), intent({ venue: 'gmx' }), intent({ accountIds: ['nope'] }), intent({ accountIds: Array(7).fill(LIVE_ID) })])59      expect((await api('POST', P + '/route', bad)).status, JSON.stringify(bad)).toBe(400);60    expect((await route({ accountIds: ['33333333-3333-4333-8333-333333333333'] })).json.error).toBe('unknown_account');61  });62});6364describe('GET /route/assets', () => {65  it('lists the bases with their venues and instruments; a restricted venue is flagged, not counted as a candidate', async () => {66    const r = await api('GET', P + '/route/assets');67    expect(r.status).toBe(200);68    const btc = r.json.assets.find((a: any) => a.base === 'BTC');69    expect(btc.venues.map((x: any) => x.venue)).toEqual(['hyperliquid', 'gmx', 'dydx']); expect(btc.candidateVenues).toBe(2);70    expect(btc.venues[0].instruments).toEqual([BTC]); expect(btc.venues[1].instruments).toContain(GMX_BTC); expect(btc.venues[1].instruments.length).toBe(4);71    expect(btc.venues[2]).toMatchObject({ instruments: [DYDX_BTC] }); expect(btc.venues[2].restriction).toContain('Canada'); expect(btc.venues[0].restriction).toBeNull();72    expect(r.json.assets[0].candidateVenues).toBe(2);                                                    // comparable bases first73    expect(r.json.assets.every((a: any) => a.venues.length >= 1)).toBe(true); expect(r.json.errors).toEqual([]);74    // a venue that cannot be read is named; the others are still listed75    v.gmx.down = true; clock.advance(3_700_000);76    const part = await api('GET', P + '/route/assets');77    expect(part.status).toBe(200); expect(part.json.errors.map((e: any) => e.venue)).toEqual(['gmx']); expect(part.json.assets.find((a: any) => a.base === 'BTC').venues.map((x: any) => x.venue)).toEqual(['hyperliquid', 'dydx']);78    v.gmx.down = false; clock.advance(2_000);                                                            // past the one-second failure backoff79  });80});8182describe('POST /route', () => {83  it('one estimate per instrument from fresh snapshots: itemised costs, sources, ranking as information, dYdX shown and never ranked', async () => {84    const before = { ...v.hl.calls };85    const r = await route();86    expect(r.status).toBe(200);87    const j = r.json;88    expect(j.source).toBe('estimate'); expect(j.ts).toBe(clock.t); expect(j.intent).toMatchObject({ base: 'BTC', side: 'long', notionalUsd: '25000', reduceOnly: false, price: null });89    expect(j.candidates.map((c: any) => c.venue)).toEqual(['hyperliquid', 'gmx', 'gmx', 'gmx', 'gmx', 'dydx']);90    const hl = cand(j, BTC), gmx = cand(j, GMX_BTC), dy = cand(j, DYDX_BTC);91    // Hyperliquid: the recorded book walked, base-tier fee, hourly funding with the venue's own last-day range as its uncertainty92    expect(hl.eligibility).toMatchObject({ eligible: true, executableNow: false }); expect(codes(hl, 'setup')).toEqual(['no_collateral_account']);93    expect(hl.fill).toMatchObject({ method: 'order_book_walk', unfilledBase: '0', source: 'estimate' });94    expect(hl.costs.map((c: any) => c.id)).toEqual(['spread_slippage', 'trading_fee', 'funding', 'collateral_transfer']);95    expect(cost(hl, 'funding').uncertainty).toMatch(/last 24 h/); expect(cost(hl, 'trading_fee').inputs[0]).toMatchObject({ value: '0.00045', source: 'constant' });96    expect(Number(cost(hl, 'collateral_transfer').usd)).toBeGreaterThan(0);97    expect(hl.risk).toEqual({ venue: { accepted: true, codes: [], note: expect.stringContaining('Hyperliquid') }, accounts: [] });98    // GMX: oracle range from the Oracle API, impact formula, execution fee and gas from the chain reader, converted at the snapshot's ETH price99    expect(gmx.eligibility.eligible).toBe(true); expect(codes(gmx, 'setup')).toEqual(['wallet_cannot_trade_here', 'no_collateral_account']);100    expect(Number(gmx.fill.gmx.oracleMax)).toBeGreaterThanOrEqual(Number(gmx.fill.gmx.oracleMin)); expect(gmx.fill.gmx.virtualInventory).toBe('none');101    expect(gmx.costs.map((c: any) => c.id)).toEqual(['spread_slippage', 'price_impact', 'trading_fee', 'execution_fee', 'network_gas', 'funding', 'borrow', 'collateral_transfer']);102    // the recorded set has no /markets/values: a market that shares a virtual inventory says its value is unknown103    expect(j.candidates.filter((c: any) => c.venue === 'gmx').map((c: any) => c.fill.gmx.virtualInventory)).toEqual(['none', 'none', 'none', 'none']);104    expect(j.chain).toMatchObject({ gasPriceEth: '0.00000000002', gmxExecutionFeeEth: '0.00008', ethUsdSource: 'Hyperliquid ETH mark price' });105    expect(Number(cost(gmx, 'execution_fee').usd)).toBeCloseTo(0.00008 * Number(j.chain.ethUsd), 5);106    expect(cost(gmx, 'collateral_transfer').usd).toBeNull(); expect(gmx.allIn.unknown).toEqual(['collateral_transfer']);107    // dYdX: public figures shown, restricted, never ranked, no account endpoint touched108    expect(dy.eligibility.eligible).toBe(false); expect(codes(dy)).toEqual(['venue_restricted']); expect(dy.eligibility.reasons[0].message).toMatch(/Canada/);109    expect(dy.fill).not.toBeNull(); expect(dy.risk.venue).toMatchObject({ accepted: false, codes: ['venue_restricted'] });110    expect(j.ranking.rows.some((x: any) => x.venue === 'dydx')).toBe(false); expect(j.ranking.excluded.map((x: any) => x.instrument)).toContain(DYDX_BTC);111    expect(v.dydx.accountCalls).toBe(0);112    // the ranking is information113    expect(j.ranking.selected).toBeNull(); expect(j.ranking.rows.length).toBe(5); expect(j.ranking.note).toMatch(/selects nothing/);114    expect(j.ranking.rows.map((x: any) => Number(x.allInUsd))).toEqual([...j.ranking.rows.map((x: any) => Number(x.allInUsd))].sort((a, b) => a - b));115    expect(j.differences.interchangeable).toBe(false); expect(j.differences.statement).toMatch(/6 different contracts on BTC/); expect(j.differences.fields.length).toBeGreaterThan(8);116    expect(j.warnings.join(' ')).toMatch(/Simulation only/); expect(j.errors).toEqual([]);117    // read-only: no trading endpoint, and only public market reads were added118    expect(v.hl.calls.exchange).toBeUndefined();119    expect(Object.keys(v.hl.calls).filter((k) => (v.hl.calls[k] ?? 0) > (before[k] ?? 0)).sort()).toEqual(['fundingHistory', 'l2Book', 'meta', 'metaAndAssetCtxs'].filter((k) => (v.hl.calls[k] ?? 0) > (before[k] ?? 0)));120    expect(await sql`select count(*)::int as n from deriv_orders`).toEqual([{ n: 0 }]);121  });122123  // v8 hardening (D2): the engine messages and the contract terms follow `locale` (query or body); the estimate itself never changes124  it('follows locale: fr translates the engine messages and the contract terms, en (default) is the engine\'s own answer; locale is not part of the intent', async () => {125    const { hasFrench } = await import('../src/derivatives/market-texts.ts');126    const en = (await route()).json, fr = (await api('POST', P + '/route?locale=fr', intent())).json, viaBody = await route({ locale: 'fr' });127    expect(en.locale).toBe('en'); expect(fr.locale).toBe('fr');128    expect(viaBody.status).toBe(200); expect(viaBody.json).toEqual(fr);                                    // the body's `locale` is accepted and stripped before the strict intent schema129    expect(fr.intent).toEqual(en.intent); expect('locale' in fr.intent).toBe(false);130    expect((await route({ locale: 'en' })).json).toEqual(en);131    // every engine message has its French sentence132    expect(fr.warnings.length).toBe(en.warnings.length);133    for (const [i, w] of en.warnings.entries()) { expect(hasFrench(w), w).toBe(true); expect(fr.warnings[i]).not.toBe(w); }134    expect(fr.warnings[0]).toMatch(/^Simulation seulement/);135    // contract terms: sentences are translated, figures and symbols are not136    const TEXT = ['structure', 'execution', 'settlement', 'quote', 'markPrice', 'fundingInterval', 'fundingRule', 'sizeUnit', 'maintenance', 'liquidation', 'fees'];137    for (const [i, c] of fr.candidates.entries()) {138      const e = en.candidates[i];139      for (const k of TEXT) { expect(hasFrench(e.terms[k].value), `${c.venue} ${k}: ${e.terms[k].value}`).toBe(true); expect(c.terms[k].source).toBe(e.terms[k].source); }140      for (const k of ['tick', 'lot']) expect(c.terms[k]).toEqual(e.terms[k]);141      expect(c.terms.collateral).toEqual({ ...e.terms.collateral, value: e.terms.collateral.value.replace(' or ', ' ou ') });142      // the estimate is the same estimate: costs, fill, margin, eligibility, risk143      const { terms: _t, ...rest } = c, { terms: _e, ...enRest } = e;144      expect(rest).toEqual(enRest);145    }146    expect(cand(fr, BTC).terms.fundingInterval.value).toBe('toutes les 1 h'); expect(cand(fr, GMX_BTC).terms.fundingInterval.value).toMatch(/^en continu/);147    expect(cand(fr, GMX_BTC).terms.maxLeverage.value).toMatch(/borne du contrat/);148    const diff = (j: any, f: string) => j.differences.fields.find((x: any) => x.field === f).values.map((x: any) => x.value);149    expect(diff(fr, 'structure')).toEqual(fr.candidates.map((c: any) => c.terms.structure.value));          // the differences table shows the same French values150    expect(fr.ranking).toEqual(en.ranking); expect(fr.chain).toEqual(en.chain); expect(fr.errors).toEqual(en.errors);151    // messages with a figure or a name152    const none = (await api('POST', P + '/route?locale=fr', intent({ base: 'ZZZZ' }))).json;153    expect(none.warnings.join(' ')).toMatch(/Aucune plateforme ne liste de contrat perpétuel sur ZZZZ\./);154  });155156  it('another base, a size in base units, a limit order, a short, a reduce-only order', async () => {157    const eth = await route({ base: 'eth', notionalUsd: undefined, sizeBase: '2', side: 'short' });158    expect(eth.status).toBe(200); expect(eth.json.intent.base).toBe('eth');159    const hl = cand(eth.json, 'hyperliquid:ETH');160    // the recorded book fixture is BTC's: the helper serves it for any symbol, which the adapter refuses → said, not estimated161    expect(hl.order === null || hl.order.side === 'sell').toBe(true);162    const g = eth.json.candidates.find((c: any) => c.venue === 'gmx' && c.instrument === 'gmx:ETH/USD [WETH-USDC]');163    expect(g.order).toMatchObject({ side: 'sell', sizeUnit: 'usd', positionSide: 'short' }); expect(Number(g.order.sizeUsd)).toBeCloseTo(2 * Number(g.fill.avgPrice), 4);164    setMarket(v, 73000); clock.advance(2_000);                                                           // past the one-second book cache165    const lim = await route({ notionalUsd: undefined, sizeBase: '1.5', type: 'limit', price: '73005' });166    expect(cand(lim.json, BTC).fill).toMatchObject({ filledBase: '1', restingBase: '0.5', unfilledReason: 'limit_price' });167    const ro = await route({ reduceOnly: true });168    expect(cand(ro.json, BTC).order.side).toBe('sell'); expect(cand(ro.json, BTC).margin).toBeNull();169    const none = await route({ base: 'ZZZZ' });170    expect(none.status).toBe(200); expect(none.json.candidates).toEqual([]); expect(none.json.warnings.join(' ')).toMatch(/No venue lists/);171    v.hl.book = null; v.hl.ticker = null;172  });173174  it('refuses to estimate on stale data, and names the venue it could not read', async () => {175    // the venue's own book timestamp is too old: the instrument is not estimated, with the reason176    v.hl.bookAgeMs = 20_000; clock.advance(2_000);177    const old = await route();178    const hl = cand(old.json, BTC);179    expect(codes(hl)).toEqual(['data_unavailable']); expect(hl.eligibility.reasons.find((r: any) => r.code === 'data_unavailable').message).toMatch(/20 s old/);180    expect(hl).toMatchObject({ fill: null, costs: [], allIn: null }); expect(old.json.ranking.rows.some((x: any) => x.venue === 'hyperliquid')).toBe(false);181    v.hl.bookAgeMs = 0;182    // the venue is down and the cached ticker is older than its bound: the venue is in `errors`, the others are still compared183    clock.advance(2_000); await route(); v.hl.down = true; clock.advance(16_000);184    const down = await route();185    expect(down.status).toBe(200); expect(down.json.errors.map((e: any) => e.venue)).toEqual(['hyperliquid']); expect(down.json.candidates.some((c: any) => c.venue === 'hyperliquid')).toBe(false);186    expect(down.json.warnings.join(' ')).toMatch(/Not read: hyperliquid/); expect(down.json.ranking.rows.every((x: any) => x.venue === 'gmx')).toBe(true);187    // a ticker served from the cache within its bound but older than the router's limit would be refused by the router itself (router.test.ts);188    // here every venue down → 503, never an estimate189    v.gmx.down = true; clock.advance(40_000);190    const all = await api('POST', P + '/route', intent({ base: 'DOGE' }));191    expect([200, 503]).toContain(all.status);192    if (all.status === 200) expect(all.json.ranking.rows).toEqual([]);193    v.hl.down = false; v.gmx.down = false; clock.advance(4_000);194  });195});196197describe('risk view: what each account\'s own policy says (nothing is placed)', () => {198  let paperId = '';199  it('a paper account: accepted, or refused with the risk engine\'s code; the estimate becomes executable as paper', async () => {200    setMarket(v, 73000, { bids: [['72999', '5'], ['72990', '50']], asks: [['73001', '5'], ['73010', '50']] });201    const made = await api('POST', P + '/accounts', { venue: 'hyperliquid', label: 'Sim routeur', collateral: '10000' });202    expect(made.status).toBe(200); paperId = made.json.id;203    const ok = await route({ notionalUsd: '5000', accountIds: [paperId] });204    const hl = cand(ok.json, BTC);205    expect(hl.risk.accounts).toEqual([{ accountId: paperId, label: 'Sim routeur', kind: 'paper', mode: 'PAPER', network: 'mainnet', accepted: true, code: null, reason: null, source: 'risk_engine' }]);206    expect(hl.eligibility).toMatchObject({ eligible: true, executableNow: true, executableAs: 'paper' });207    expect(cost(hl, 'collateral_transfer').usd).toBe('0'); expect(cost(hl, 'collateral_transfer').basis).toMatch(/SIMULATED/);208    expect(cand(ok.json, GMX_BTC).risk.accounts).toEqual([]);                                            // the account is not on GMX209    // the default paper policy: one order ≤ the collateral, leverage ≤ 3210    const big = await route({ notionalUsd: '25000', leverage: '2', accountIds: [paperId] });                // 12 500 USD of margin on 10 000211    expect(cand(big.json, BTC).risk.accounts[0]).toMatchObject({ accepted: false, code: 'order_notional_exceeded', source: 'risk_engine' });212    expect(cand(big.json, BTC).risk.venue.accepted).toBe(true);                                          // the venue's own limits would take it213    expect(codes(cand(big.json, BTC), 'setup')).toEqual(['insufficient_collateral_there']);214    const lev = await route({ notionalUsd: '5000', leverage: '5', accountIds: [paperId] });215    expect(cand(lev.json, BTC).risk.accounts[0]).toMatchObject({ accepted: false, code: 'leverage_exceeded' });216    // nothing was stored or placed by any of this217    expect(await sql`select count(*)::int as n from deriv_orders`).toEqual([{ n: 0 }]);218    expect((await api('GET', `${P}/accounts/${paperId}`)).json).toMatchObject({ positions: [], openOrders: [], orders: [] });219    expect(v.hl.calls.exchange).toBeUndefined();220  });221222  it('a venue account: its collateral is read from the venue; a read-only one refuses with its own code; a testnet one is not asked', async () => {223    const r = await route({ notionalUsd: '1000', accountIds: [LIVE_ID] });224    const hl = cand(r.json, BTC);225    expect(hl.risk.accounts).toEqual([{ accountId: LIVE_ID, label: 'HL réel', kind: 'venue', mode: 'READ_ONLY', network: 'mainnet', accepted: false, code: 'mode_read_only', reason: expect.stringContaining('read-only'), source: 'risk_engine' }]);226    expect(cost(hl, 'collateral_transfer').inputs[0]).toMatchObject({ source: 'venue' });                // the venue's own free collateral227    expect(hl.eligibility.executableAs).toBe('paper');                                                   // not authorized for live: still only a simulation228    expect(livePreviews.length).toBe(1); expect((livePreviews[0] as any).body).toMatchObject({ instrument: BTC, side: 'buy', type: 'market', reduceOnly: false, slippageBps: 50 });229    const t = await route({ notionalUsd: '1000', accountIds: [TESTNET_ID] });230    expect(cand(t.json, BTC).risk.accounts[0]).toMatchObject({ accepted: null, source: 'not_checked' }); expect(cand(t.json, BTC).risk.accounts[0].reason).toMatch(/testnet/);231    expect(livePreviews.length).toBe(1);232    v.hl.book = null; v.hl.ticker = null;233  });234});235
Files main
Menu

Theme