Repository Public
1// Every route of the derivatives API contract (docs/v4/DERIVATIVES-ARCHITECTURE.md, wave 2) through the real server: owner2// session required, zod validation, happy paths on fixture-backed venue adapters; the market-data service (caches, stale3// bounds, venue health, comparison) and the shared stream. Database spw_deriv_test (created here if missing). No network.4import { createHash, randomBytes } from 'node:crypto';5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import { BTC, Clock, DYDX_BTC, GMX_ACCOUNT, GMX_BTC, HL_ACCOUNT, T0, fixtureVenues, setMarket, useDatabase, type Venues } from './derivatives-helpers.ts';78const ORIGIN = 'https://wallet.test', P = '/api/v1/derivatives';9let app: any, sql: any, cookie = '', clock: Clock, v: Venues, d: import('../src/derivatives/index.ts').Derivatives, mod: typeof import('../src/derivatives/index.ts');10let seq = 0;11const key = () => `api-key-${++seq}-${Math.random().toString(36).slice(2, 10)}`;12const settle = (ms = 25) => new Promise((r) => setTimeout(r, ms));1314async function api(method: 'GET' | 'POST', path: string, body?: unknown, opts: { cookie?: string | null; idem?: string; csrf?: boolean } = {}) {15 const headers: Record<string, string> = {};16 const c = opts.cookie === undefined ? cookie : opts.cookie;17 if (c) headers.cookie = c;18 if (method !== 'GET') { headers.origin = ORIGIN; if (opts.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }19 if (opts.idem) headers['idempotency-key'] = opts.idem;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 get = (path: string) => api('GET', path);24const q = (o: Record<string, string | number>) => `?${Object.entries(o).map(([k, x]) => `${k}=${encodeURIComponent(String(x))}`).join('&')}`;2526beforeAll(async () => {27 await useDatabase('deriv_api');28 const db = await import('../src/db.ts');29 sql = db.sql;30 await db.migrate();31 await sql`truncate deriv_paper_accounts cascade`;32 const token = randomBytes(32).toString('base64url');33 await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;34 cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;35 mod = await import('../src/derivatives/index.ts');36 clock = new Clock(); v = fixtureVenues(clock);37 d = mod.createDerivatives({ registry: mod.createRegistry(v.adapters, clock.now), now: clock.now });38 app = await (await import('../src/server.ts')).buildServer({ derivatives: d });39});40afterAll(async () => { app?.server.closeAllConnections?.(); await app?.close(); await sql?.end(); });4142describe('access', () => {43 it('every derivatives route needs the owner session; writes need the CSRF header', async () => {44 const id = '00000000-0000-4000-8000-000000000000';45 const gets = ['/venues', '/instruments', '/tickers', `/market${q({ instrument: BTC })}`, `/candles${q({ instrument: BTC })}`, `/funding${q({ instrument: BTC })}`, `/stream${q({ instrument: BTC })}`,46 '/compare', '/accounts', `/accounts/${id}`, `/watch${q({ account: HL_ACCOUNT, venue: 'hyperliquid' })}`];47 for (const p of gets) expect((await api('GET', P + p, undefined, { cookie: null })).status, p).toBe(401);48 const posts = ['/accounts', `/accounts/${id}/policy`, `/accounts/${id}/orders/preview`, `/accounts/${id}/orders`, `/accounts/${id}/orders/abc/cancel`, `/accounts/${id}/close`, `/accounts/${id}/reset`];49 for (const p of posts) {50 expect((await api('POST', P + p, {}, { cookie: null, idem: key() })).status, p).toBe(401);51 expect((await api('POST', P + p, {}, { csrf: false, idem: key() })).status, p).toBe(403);52 }53 expect((await api('GET', P + '/venues', undefined, { cookie: 'spw=forged; __Host-spw=forged' })).status).toBe(401);54 expect(v.hl.calls).toEqual({}); // nothing reached a venue55 });56});5758describe('venues and market data', () => {59 it('GET /venues: three venues, capabilities as offered, dYdX restricted to market data with the reason', async () => {60 const r = await get(P + '/venues');61 expect(r.status).toBe(200);62 expect(r.json.venues.map((x: any) => [x.id, x.network, x.status, x.paper, x.orderBook])).toEqual([['hyperliquid', 'mainnet', 'ok', true, true], ['gmx', 'mainnet', 'ok', false, false], ['dydx', 'mainnet', 'ok', false, true]]);63 const [hl, gmx, dydx] = r.json.venues;64 expect(hl).toMatchObject({ name: 'Hyperliquid', restriction: null, lastOkAt: T0 });65 expect(hl.capabilities).toMatchObject({ marketData: true, account: true, stream: true });66 expect(gmx.capabilities).toMatchObject({ marketData: true, account: true, stream: false, orders: false });67 expect(dydx.restriction).toContain('Canada'); expect(dydx.restriction).toContain('geoblocks');68 expect(dydx.capabilities).toMatchObject({ marketData: true, account: false, orders: false, orderTypes: [] });69 expect(dydx.capabilities.notes[0]).toBe(dydx.restriction);70 expect(r.json.bounds.tickers).toEqual({ ttl: 3000, maxAge: 15000 });71 expect(r.json.ts).toBe(T0);72 });7374 it('GET /instruments: per venue or all, active first, cached 10 minutes', async () => {75 const hl = await get(P + '/instruments?venue=hyperliquid');76 expect(hl.status).toBe(200);77 expect(hl.json.instruments[0]).toMatchObject({ id: BTC, venue: 'hyperliquid', symbol: 'BTC', base: 'BTC', collateral: 'USDC', lotSize: '0.00001', minNotional: '10', fundingIntervalSec: 3600, status: 'active' });78 const st = hl.json.instruments.map((i: any) => i.status), firstHalted = st.indexOf('halted');79 expect(firstHalted).toBeGreaterThan(0);80 expect(st.slice(firstHalted).every((s: string) => s === 'halted')).toBe(true);81 const metaCalls = v.hl.calls.meta;82 clock.advance(9 * 60_000);83 expect((await get(P + '/instruments?venue=hyperliquid')).json.ts).toBe(hl.json.ts);84 expect(v.hl.calls.meta).toBe(metaCalls); // served from the cache85 clock.advance(61_000);86 expect((await get(P + '/instruments?venue=hyperliquid')).json.ts).toBe(clock.t);87 const all = await get(P + '/instruments');88 expect(new Set(all.json.instruments.map((i: any) => i.venue))).toEqual(new Set(['hyperliquid', 'gmx', 'dydx']));89 expect(all.json.errors).toEqual([]);90 expect(all.json.instruments.find((i: any) => i.id === GMX_BTC)).toMatchObject({ base: 'BTC', collateral: 'BTC or USDC', marginModes: ['isolated'] });91 expect(all.json.instruments.find((i: any) => i.id === DYDX_BTC)).toMatchObject({ base: 'BTC', tickSize: '1', lotSize: '0.0001' });92 expect((await get(P + '/instruments?venue=binance')).status).toBe(404);93 expect((await get(P + `/instruments?venue=${'x'.repeat(50)}`)).status).toBe(400);94 });9596 it('GET /tickers: cached at most 3 s, every ticker and the answer carry their time', async () => {97 setMarket(v, 50_000);98 clock.advance(4_000);99 const a = await get(P + '/tickers?venue=hyperliquid');100 expect(a.status).toBe(200);101 expect(a.json).toMatchObject({ ts: clock.t, source: 'venue' });102 expect(a.json.tickers.find((t: any) => t.instrument === BTC)).toEqual({ instrument: BTC, mark: '50000', index: '50000', mid: '50000', funding: '0.0000125', openInterest: expect.any(String), volume24h: expect.any(String), change24h: expect.any(String), ts: clock.t });103 const calls = v.hl.calls.metaAndAssetCtxs, t1 = clock.t;104 setMarket(v, 50_100); clock.advance(2_900);105 const b = await get(P + '/tickers?venue=hyperliquid');106 expect(b.json.ts).toBe(t1); expect(v.hl.calls.metaAndAssetCtxs).toBe(calls); // within 3 s: the cached answer, with its own time107 clock.advance(200);108 const c = await get(P + '/tickers?venue=hyperliquid');109 expect(c.json.ts).toBe(clock.t);110 expect(c.json.tickers.find((t: any) => t.instrument === BTC).mark).toBe('50100');111 const all = await get(P + '/tickers');112 expect(all.json.errors).toEqual([]);113 expect(all.json.tickers.find((t: any) => t.instrument === DYDX_BTC)).toMatchObject({ mid: null });114 expect(all.json.tickers.find((t: any) => t.instrument === GMX_BTC)).toMatchObject({ mid: null });115 expect((await get(P + '/tickers?venue=nope')).status).toBe(404);116 });117118 it('GET /market: ticker, book and trades on a book venue; liquidity instead of a book on GMX', async () => {119 setMarket(v, 50_000); clock.advance(4_000);120 const m = await get(P + `/market${q({ instrument: BTC })}`);121 expect(m.status).toBe(200);122 expect(m.json).toMatchObject({ instrument: { id: BTC }, ticker: { mark: '50000', source: 'venue', ts: clock.t }, liquidity: null, errors: {}, ts: clock.t, source: 'venue' });123 expect(m.json.book).toEqual({ instrument: BTC, bids: [['49995', '1'], ['49990', '2']], asks: [['50005', '1'], ['50010', '2']], ts: clock.t, source: 'venue' });124 expect(m.json.trades.length).toBeGreaterThan(0);125 expect(m.json.trades[0]).toMatchObject({ instrument: BTC, price: expect.any(String), side: expect.stringMatching(/^(buy|sell)$/) });126 expect(m.json.trades[0].ts).toBeGreaterThanOrEqual(m.json.trades.at(-1).ts);127 const g = await get(P + `/market${q({ instrument: GMX_BTC })}`);128 expect(g.status).toBe(200);129 expect(g.json.book).toBeNull();130 expect(g.json.liquidity).toMatchObject({ instrument: GMX_BTC, source: 'venue', capacityLongUsd: expect.any(String), priceImpact: { factorNegative: expect.any(String) }, positionFeeFactor: { balanceImproved: '0.0004', balanceNotImproved: '0.0006' } });131 expect(g.json.ticker.mid).toBeNull();132 const dy = await get(P + `/market${q({ instrument: DYDX_BTC })}`);133 expect(dy.json.book.bids[0]).toEqual(['73587', '0.1358']);134 expect(dy.json.book.bids.length).toBeLessThanOrEqual(20);135 expect((await get(P + `/market${q({ instrument: 'hyperliquid:NOPE' })}`)).status).toBe(404);136 expect((await get(P + `/market${q({ instrument: 'nowhere:BTC' })}`)).status).toBe(404);137 expect((await get(P + `/market${q({ instrument: 'BTC' })}`)).json.error).toBe('bad_instrument');138 expect((await get(P + '/market')).status).toBe(400);139 });140141 it('GET /candles and GET /funding', async () => {142 const c = await get(P + `/candles${q({ instrument: BTC, interval: '1h', limit: 5 })}`);143 expect(c.status).toBe(200);144 expect(c.json).toMatchObject({ instrument: BTC, interval: '1h', source: 'venue', ts: clock.t });145 expect(c.json.candles).toHaveLength(5);146 expect(c.json.candles[0]).toEqual({ ts: expect.any(Number), open: expect.any(String), high: expect.any(String), low: expect.any(String), close: expect.any(String), volume: expect.any(String) });147 expect(c.json.candles[4].ts).toBeGreaterThan(c.json.candles[0].ts);148 expect((await get(P + `/candles${q({ instrument: GMX_BTC, interval: '1h', limit: 3 })}`)).json.candles).toHaveLength(3);149 for (const bad of [{ instrument: BTC, interval: '2h' }, { instrument: BTC, limit: 0 }, { instrument: BTC, limit: 5000 }, { interval: '1h' }])150 expect((await get(P + `/candles${q(bad as never)}`)).status, JSON.stringify(bad)).toBe(400);151 expect((await get(P + `/candles${q({ instrument: 'hyperliquid:NOPE' })}`)).status).toBe(404);152153 const f = await get(P + `/funding${q({ instrument: BTC, hours: 24 })}`);154 expect(f.status).toBe(200);155 expect(f.json).toMatchObject({ instrument: BTC, intervalSec: 3600, source: 'venue' });156 expect(f.json.history.length).toBeGreaterThan(0);157 expect(f.json.history[0]).toEqual({ ts: expect.any(Number), rate: expect.stringMatching(/^-?\d+(\.\d+)?$/) });158 expect((await get(P + `/funding${q({ instrument: DYDX_BTC, hours: 48 })}`)).json).toMatchObject({ intervalSec: 3600 });159 for (const bad of [{ instrument: BTC, hours: 0 }, { instrument: BTC, hours: 481 }, { instrument: BTC, hours: 'x' }, { hours: 2 }])160 expect((await get(P + `/funding${q(bad as never)}`)).status, JSON.stringify(bad)).toBe(400);161 });162163 it('GET /compare: one row per instrument, funding per hour with the venue interval, spread and depth from the real book, null on GMX — a table, not advice', async () => {164 setMarket(v, 50_000); clock.advance(4_000);165 const r = await get(P + '/compare?base=btc');166 expect(r.status).toBe(200);167 expect(r.json).toMatchObject({ base: 'BTC', errors: [], ts: clock.t });168 const hl = r.json.rows.find((x: any) => x.venue === 'hyperliquid'), dy = r.json.rows.find((x: any) => x.venue === 'dydx'), gm = r.json.rows.filter((x: any) => x.venue === 'gmx');169 expect(Object.keys(hl).sort()).toEqual(['collateral', 'depthAskUsd1pct', 'depthBidUsd1pct', 'depthUsd1pct', 'funding', 'fundingVenue', 'index', 'instrument', 'mark', 'maxLeverage', 'notes', 'restriction', 'settlement',170 'source', 'spreadBps', 'takerFeeBps', 'ts', 'venue'].sort());171 expect(hl).toMatchObject({ instrument: BTC, mark: '50000', index: '50000', funding: '0.0000125', fundingVenue: { rate: '0.0000125', intervalSec: 3600 }, spreadBps: '2', depthBidUsd1pct: '149975', depthAskUsd1pct: '150025',172 depthUsd1pct: '300000', takerFeeBps: '4.5', maxLeverage: '40', collateral: 'USDC', restriction: null, source: 'venue' });173 expect(hl.notes.join(' ')).toMatch(/lower bound/); // the two-level book ends before 1 %174 expect(dy).toMatchObject({ instrument: DYDX_BTC, takerFeeBps: '5', maxLeverage: '50', collateral: 'USDC' });175 expect(dy.restriction).toContain('Canada');176 expect(Number(dy.spreadBps)).toBeGreaterThan(0); expect(Number(dy.depthUsd1pct)).toBeGreaterThan(0);177 expect(gm.length).toBeGreaterThan(1); // one instrument per pool178 const pool = gm.find((x: any) => x.instrument === GMX_BTC);179 expect(pool).toMatchObject({ spreadBps: null, depthUsd1pct: null, depthBidUsd1pct: null, depthAskUsd1pct: null, takerFeeBps: '6', fundingVenue: { intervalSec: 3600 } });180 expect(pool.notes.join(' ')).toMatch(/Capacity still open: long \d+ USD/);181 expect(pool.notes.join(' ')).toMatch(/Price impact/);182 expect(JSON.stringify(r.json)).not.toMatch(/recommend|best|cheapest|winner/i);183 expect(r.json.rows.map((x: any) => x.venue)).toEqual([...r.json.rows.map((x: any) => x.venue)].sort((a: string, b: string) => ['hyperliquid', 'gmx', 'dydx'].indexOf(a) - ['hyperliquid', 'gmx', 'dydx'].indexOf(b)));184 expect((await get(P + '/compare?base=ZZZZ')).json).toMatchObject({ rows: [], errors: [] });185 expect((await get(P + '/compare?base=B/TC')).status).toBe(400);186 });187188 // v8 hardening (D2): the server-authored sentences of the read routes follow `?locale=`189 it('GET /compare and /instruments follow ?locale=: default en is the same strings, fr translates every sentence and nothing else', async () => {190 const { hasFrench } = await import('../src/derivatives/market-texts.ts');191 const en = (await get(P + '/compare?base=btc')).json, dflt = (await get(P + '/compare?base=btc&locale=en')).json, fr = (await get(P + '/compare?base=btc&locale=fr')).json;192 expect(en.locale).toBe('en'); expect(fr.locale).toBe('fr');193 expect(dflt).toEqual(en); // `en` is the default194 expect((await get(P + '/compare?base=btc&locale=de')).json.locale).toBe('en'); // an unknown language is English, not an error195 const { locale: _l, ...enRest } = en;196 expect(enRest).toEqual(await d.data.compare('btc')); // default: the service's own answer, untouched197 expect(fr.rows.length).toBe(en.rows.length);198 for (const [i, r] of fr.rows.entries()) {199 const e = en.rows[i];200 const { settlement: _s, notes: _n, restriction: _r, collateral: _c, ...figures } = r, { settlement: _es, notes: _en, restriction: _er, collateral: _ec, ...enFigures } = e;201 expect(r.collateral).toBe(e.collateral.replace(' or ', ' ou ')); // symbols stay; only the « or » of a two-token collateral is a word202 expect(figures).toEqual(enFigures); // codes, ids and numbers never change with the language203 expect(r.settlement).not.toBe(e.settlement);204 expect(r.notes.length).toBe(e.notes.length);205 for (const [k, n] of e.notes.entries()) { expect(hasFrench(n), n).toBe(true); expect(r.notes[k]).not.toBe(n); } // every note the table shows has its French sentence206 if (e.restriction !== null) expect(r.restriction).not.toBe(e.restriction); else expect(r.restriction).toBeNull();207 }208 const hl = fr.rows.find((x: any) => x.venue === 'hyperliquid'), dy = fr.rows.find((x: any) => x.venue === 'dydx'), pool = fr.rows.find((x: any) => x.instrument === GMX_BTC);209 expect(hl.settlement).toMatch(/^Contrat perpétuel linéaire/); expect(hl.notes.join(' ')).toMatch(/borne inférieure/);210 expect(dy.restriction).toMatch(/exclu.*Canada/);211 const enPool = en.rows.find((x: any) => x.instrument === GMX_BTC);212 const figuresOf = (s: string) => (s.match(/\d+(?:[.,]\d+)?/g) ?? []).map((x) => x.replace(',', '.'));213 expect(pool.notes.join(' ')).toMatch(/Capacité encore ouverte : long \d+ USD/); expect(enPool.collateral).toMatch(/ or /); expect(pool.collateral).toMatch(/^\S+ ou \S+$/);214 expect(pool.notes.map(figuresOf)).toEqual(enPool.notes.map(figuresOf)); // the figures inside a sentence are carried over unchanged215 expect(JSON.stringify(fr)).not.toMatch(/recommand|meilleur|gagnant/i);216217 const ie = (await get(P + '/instruments')).json, ifr = (await get(P + '/instruments?locale=fr')).json;218 expect(ie.locale).toBe('en'); expect(ifr.locale).toBe('fr'); expect(ifr.instruments.length).toBe(ie.instruments.length);219 for (const [i, x] of ifr.instruments.entries()) {220 const e = ie.instruments[i];221 expect({ ...x, settlement: e.settlement, markPriceMethod: e.markPriceMethod, collateral: e.collateral }).toEqual(e); expect(x.collateral).toBe(e.collateral.replace(' or ', ' ou '));222 expect(x.settlement).not.toBe(e.settlement); expect(x.markPriceMethod).not.toBe(e.markPriceMethod);223 }224 expect((await get(P + '/instruments?venue=gmx&locale=fr')).json.instruments[0].markPriceMethod).toMatch(/^pas de carnet d’ordres/);225 });226227 it('GET /watch: a public address on Hyperliquid or GMX; refused on dYdX with the restriction text, without calling it', async () => {228 const r = await get(P + `/watch${q({ account: HL_ACCOUNT, venue: 'hyperliquid' })}`);229 expect(r.status).toBe(200);230 expect(r.json).toMatchObject({ venue: 'hyperliquid', account: HL_ACCOUNT, source: 'venue', ts: clock.t, state: { venue: 'hyperliquid', source: 'venue', equity: '6515.769962' } });231 expect(r.json.positions.map((p: any) => p.instrument).slice(0, 2)).toEqual(['hyperliquid:BTC', 'hyperliquid:ETH']);232 expect(r.json.positions[0].source).toBe('venue');233 expect(r.json.openOrders).toHaveLength(7);234 const g = await get(P + `/watch${q({ account: GMX_ACCOUNT, venue: 'gmx' })}`);235 expect(g.status).toBe(200);236 expect(g.json.positions.length).toBeGreaterThan(0);237 expect(g.json.state).toMatchObject({ venue: 'gmx', freeCollateral: '0' });238 const dy = await get(P + `/watch${q({ account: 'dydx1zzz66hkkjy6apat9w2enw37484v3xhqnlducf7', venue: 'dydx' })}`);239 expect(dy.status).toBe(403);240 expect(dy.json.error).toBe('venue_restricted');241 expect(dy.json.message).toContain('Canada'); expect(dy.json.message).toContain('market data only');242 expect((await get(P + `/watch${q({ account: HL_ACCOUNT, venue: 'dydx' })}`)).status).toBe(403);243 expect(v.dydx.accountCalls).toBe(0);244 // even reaching past the routes, the adapter view refuses245 const direct = await d.registry.get('dydx').adapter.account('dydx1zzz66hkkjy6apat9w2enw37484v3xhqnlducf7').then(() => null, (e: any) => e);246 expect(direct).toMatchObject({ code: 'venue_restricted', status: 403 });247 expect(v.dydx.accountCalls).toBe(0);248 expect((await get(P + `/watch${q({ account: '0x1234', venue: 'hyperliquid' })}`)).json.error).toBe('bad_account');249 expect((await get(P + `/watch${q({ account: HL_ACCOUNT, venue: 'nope' })}`)).status).toBe(404);250 expect((await get(P + `/watch${q({ account: HL_ACCOUNT })}`)).status).toBe(400);251 // the read view exposes no trading object and no raw client252 for (const e of d.registry.list()) expect(Object.keys(e.adapter).filter((k) => /trading|http|loadTrading|close/i.test(k))).toEqual([]);253 expect(v.hl.calls.exchange).toBeUndefined();254 });255});256257describe('paper account routes', () => {258 let id = '';259 it('POST /accounts creates a paper account; GET /accounts and /accounts/:id read it', async () => {260 for (const bad of [{}, { venue: 'hyperliquid', label: 'x' }, { venue: 'hyperliquid', label: '', collateral: '1000' }, { venue: 'hyperliquid', label: 'x', collateral: 'abc' }, { venue: 'hyperliquid', label: 'x', collateral: '-5' },261 { venue: 'hyperliquid', label: 'x', collateral: '5' }, { venue: 'hyperliquid', label: 'x', collateral: 1000 }, { venue: 'hyperliquid', label: 'x', collateral: '1000', mode: 'MANUAL_LIVE' },262 { venue: 'hyperliquid', label: 'x', collateral: '1000', policy: { maxLeverage: '-1' } }, { venue: 'hyperliquid', label: 'x', collateral: '1000', policy: { unknown: 1 } }])263 expect((await api('POST', P + '/accounts', bad)).status, JSON.stringify(bad)).toBe(400);264 const dy = await api('POST', P + '/accounts', { venue: 'dydx', label: 'x', collateral: '1000' });265 expect(dy.status).toBe(403); expect(dy.json.error).toBe('venue_restricted'); expect(dy.json.message).toContain('Canada');266 expect((await api('POST', P + '/accounts', { venue: 'gmx', label: 'x', collateral: '1000' })).json.error).toBe('paper_unsupported');267 expect((await api('POST', P + '/accounts', { venue: 'hyperliquid', label: 'x', collateral: '1000', policy: { mode: 'MANDATE_LIVE' } })).json.error).toBe('live_not_available');268269 const r = await api('POST', P + '/accounts', { venue: 'hyperliquid', label: 'Main paper', collateral: '10000' });270 expect(r.status).toBe(200);271 expect(r.json).toMatchObject({ venue: 'hyperliquid', network: 'mainnet', kind: 'paper', mode: 'PAPER', label: 'Main paper', policy: { mode: 'PAPER', maxLeverage: '3', halt: null },272 state: { equity: '10000', source: 'paper' }, positions: [], openOrders: [] });273 id = r.json.id;274 const list = await get(P + '/accounts');275 expect(list.json.accounts).toHaveLength(1);276 expect(Object.keys(list.json.accounts[0])).toEqual(expect.arrayContaining(['id', 'venue', 'network', 'kind', 'mode', 'label', 'policy', 'state', 'positions', 'openOrders']));277 const one = await get(P + `/accounts/${id}`);278 expect(one.status).toBe(200);279 expect(Object.keys(one.json)).toEqual(expect.arrayContaining(['orders', 'fills', 'funding', 'riskEvents', 'equity']));280 expect((await get(P + '/accounts/not-a-uuid')).status).toBe(400);281 expect((await get(P + '/accounts/00000000-0000-4000-8000-000000000000')).status).toBe(404);282 });283284 it('POST /accounts/:id/orders/preview estimates without placing', async () => {285 setMarket(v, 50_000); clock.advance(4_000);286 const body = { instrument: BTC, side: 'buy', type: 'market', size: '0.1', price: '50200', triggerPrice: null, reduceOnly: false, tif: 'ioc' };287 const r = await api('POST', P + `/accounts/${id}/orders/preview`, body);288 expect(r.status).toBe(200);289 expect(r.json.risk).toEqual({ ok: true });290 expect(Object.keys(r.json.estimate)).toEqual(expect.arrayContaining(['notional', 'initialMargin', 'fee', 'avgFillPrice', 'slippageBps', 'liquidationPrice', 'freeCollateralAfter']));291 expect(r.json.estimate).toMatchObject({ notional: '5000.5', fee: '2.250225', avgFillPrice: '50005', slippageBps: '1', source: 'estimate' });292 const lev = await api('POST', P + `/accounts/${id}/orders/preview`, { ...body, leverage: '2' });293 expect(lev.json.estimate).toMatchObject({ leverage: '2', initialMargin: '2500.25' });294 const refused = await api('POST', P + `/accounts/${id}/orders/preview`, { ...body, size: '0.5' });295 expect(refused.status).toBe(200);296 expect(refused.json.risk).toMatchObject({ ok: false, code: 'order_notional_exceeded', reason: expect.any(String) });297 for (const bad of [{}, { ...body, side: 'long' }, { ...body, size: '0' }, { ...body, size: 0.1 }, { ...body, size: '1e-3' }, { ...body, type: 'twap' }, { ...body, tif: 'fok' }, { ...body, clientId: 'x' }, { ...body, leverage: '0' },298 { ...body, slippageBps: 5000 }, { ...body, price: '-1' }])299 expect((await api('POST', P + `/accounts/${id}/orders/preview`, bad)).status, JSON.stringify(bad)).toBe(400);300 expect((await api('POST', P + '/accounts/00000000-0000-4000-8000-000000000000/orders/preview', body)).status).toBe(404);301 expect((await get(P + `/accounts/${id}`)).json).toMatchObject({ orders: [], positions: [] });302 });303304 it('POST /accounts/:id/orders: Idempotency-Key required, risk engine then paper engine, refusals are 422 with the risk code', async () => {305 const body = { instrument: BTC, side: 'buy', type: 'market', size: '0.1', price: '50200', triggerPrice: null, reduceOnly: false, tif: 'ioc' };306 const noKey = await api('POST', P + `/accounts/${id}/orders`, body);307 expect(noKey.status).toBe(400); expect(noKey.json.error).toBe('idempotency');308 expect((await api('POST', P + `/accounts/${id}/orders`, body, { idem: 'short' })).json.error).toBe('idempotency');309 expect((await api('POST', P + `/accounts/${id}/orders`, { ...body, size: 'x' }, { idem: key() })).status).toBe(400);310 const k = key(), r = await api('POST', P + `/accounts/${id}/orders`, body, { idem: k });311 expect(r.status).toBe(200);312 expect(r.json.order).toMatchObject({ clientId: k, status: 'filled', filled: '0.1', avgFillPrice: '50005', instrument: BTC, side: 'buy', venueOrderId: 'paper-o-1' });313 expect(r.json.fills).toHaveLength(1);314 expect(r.json.fills[0]).toMatchObject({ price: '50005', size: '0.1', fee: '2.250225', clientId: k });315 const again = await api('POST', P + `/accounts/${id}/orders`, { ...body, size: '0.05' }, { idem: k });316 expect(again.status).toBe(200);317 expect(again.json).toMatchObject({ idempotent: true, order: r.json.order, fills: r.json.fills });318 for (const [patch, code] of [[{ size: '0.3' }, 'order_notional_exceeded'], [{ price: '60000' }, 'price_deviation'], [{ size: '0.000001' }, 'size_not_on_lot'], [{ instrument: 'hyperliquid:ATOM' }, null],319 [{ type: 'limit', price: '49900.5', tif: 'gtc' }, 'price_not_on_tick'], [{ leverage: '10' }, 'leverage_exceeded'], [{ reduceOnly: true }, 'reduce_only_would_increase']] as const) {320 const x = await api('POST', P + `/accounts/${id}/orders`, { ...body, ...patch }, { idem: key() });321 if (code === null) { expect(x.status).toBeGreaterThanOrEqual(400); continue; }322 expect(x.status, JSON.stringify(patch)).toBe(422);323 expect(x.json).toEqual({ error: code, message: expect.any(String) });324 }325 const a = (await get(P + `/accounts/${id}`)).json;326 expect(a.positions).toHaveLength(1);327 expect(a.positions[0]).toMatchObject({ instrument: BTC, side: 'long', size: '0.1', source: 'paper' });328 expect(a.orders).toHaveLength(1); expect(a.fills).toHaveLength(1);329 expect(a.riskEvents.filter((e: any) => e.kind === 'order_refused').length).toBe(6);330 expect(a.equity.length).toBeGreaterThan(0);331 });332333 it('POST …/orders/:clientId/cancel, /close, /policy, /reset', async () => {334 const k = key();335 const lim = await api('POST', P + `/accounts/${id}/orders`, { instrument: BTC, side: 'buy', type: 'limit', size: '0.05', price: '49900' }, { idem: k });336 expect(lim.json.order.status).toBe('open');337 expect((await get(P + '/accounts')).json.accounts[0].openOrders).toHaveLength(1);338 const c = await api('POST', P + `/accounts/${id}/orders/${k}/cancel`);339 expect(c.status).toBe(200); expect(c.json.order).toMatchObject({ clientId: k, status: 'cancelled' });340 expect((await api('POST', P + `/accounts/${id}/orders/unknown-order/cancel`)).status).toBe(404);341 expect((await api('POST', P + `/accounts/not-a-uuid/orders/${k}/cancel`)).status).toBe(400);342343 for (const bad of [{}, { instrument: BTC }, { instrument: BTC, fraction: '0' }, { instrument: BTC, fraction: '1.5' }, { instrument: BTC, fraction: 0.5 }, { instrument: BTC, fraction: '0.5', extra: 1 }])344 expect((await api('POST', P + `/accounts/${id}/close`, bad)).status, JSON.stringify(bad)).toBe(400);345 expect((await api('POST', P + `/accounts/${id}/close`, { instrument: 'hyperliquid:ETH', fraction: '1' })).json.error).toBe('no_position');346 const half = await api('POST', P + `/accounts/${id}/close`, { instrument: BTC, fraction: '0.5' });347 expect(half.status).toBe(200);348 expect(half.json).toMatchObject({ worstPrice: '49746', slippageBoundBps: 50, order: { side: 'sell', type: 'market', reduceOnly: true, size: '0.05', status: 'filled', avgFillPrice: '49995' } });349 expect(half.json.fills).toHaveLength(1);350 const ck = key(), rest = await api('POST', P + `/accounts/${id}/close`, { instrument: BTC, fraction: '1', slippageBps: 100 }, { idem: ck });351 expect(rest.json).toMatchObject({ slippageBoundBps: 100, worstPrice: '49496', order: { size: '0.05', status: 'filled' } });352 expect((await api('POST', P + `/accounts/${id}/close`, { instrument: BTC, fraction: '1' }, { idem: ck })).json).toMatchObject({ idempotent: true, order: rest.json.order });353 expect((await get(P + `/accounts/${id}`)).json.positions).toEqual([]);354355 const policy = (await get(P + `/accounts/${id}`)).json.policy;356 for (const bad of [{}, { ...policy, maxLeverage: '0' }, { ...policy, extra: true }, { ...policy, mode: 'TURBO' }, { maxLeverage: '2' }])357 expect((await api('POST', P + `/accounts/${id}/policy`, bad)).status, JSON.stringify(bad)).toBe(400);358 expect((await api('POST', P + `/accounts/${id}/policy`, { ...policy, mode: 'MANUAL_LIVE' })).json.error).toBe('live_not_available');359 const ro = await api('POST', P + `/accounts/${id}/policy`, { ...policy, mode: 'READ_ONLY', maxLeverage: '2' });360 expect(ro.status).toBe(200);361 expect(ro.json).toMatchObject({ mode: 'READ_ONLY', policy: { mode: 'READ_ONLY', maxLeverage: '2' } });362 const blocked = await api('POST', P + `/accounts/${id}/orders`, { instrument: BTC, side: 'buy', type: 'market', size: '0.01', slippageBps: 50 }, { idem: key() });363 expect(blocked.status).toBe(422); expect(blocked.json.error).toBe('mode_read_only');364 expect((await api('POST', P + `/accounts/${id}/policy`, policy)).json.mode).toBe('PAPER');365 expect((await api('POST', P + '/accounts/00000000-0000-4000-8000-000000000000/policy', policy)).status).toBe(404);366367 const reset = await api('POST', P + `/accounts/${id}/reset`);368 expect(reset.status).toBe(200);369 expect(reset.json).toMatchObject({ state: { collateral: '10000', equity: '10000' }, orders: [], fills: [], positions: [] });370 expect((await api('POST', P + '/accounts/00000000-0000-4000-8000-000000000000/reset')).status).toBe(404);371 const types = (await sql`select type from audit_events where data->>'account' = ${id} order by seq`).map((x: any) => x.type);372 expect(types).toEqual(['deriv.paper.account_created', 'deriv.paper.order', 'deriv.paper.order', 'deriv.paper.cancel', 'deriv.paper.close', 'deriv.paper.close',373 'deriv.paper.policy_replaced', 'deriv.paper.policy_replaced', 'deriv.paper.reset']);374 });375});376377describe('shared stream', () => {378 it('two listeners share one upstream; a gap is announced and followed by a fresh snapshot; the upstream closes with the last listener', async () => {379 setMarket(v, 50_000); clock.advance(4_000);380 const inst = await d.data.instrument(BTC), opened = d.data.streamStats.opened, closed = d.data.streamStats.closed;381 const a: any[] = [], b: any[] = [];382 const offA = d.data.subscribe(inst, (e) => a.push(e));383 expect(v.hl.stream.open().map((s) => s.type).sort()).toEqual(['book', 'ticker', 'trades']);384 expect(v.hl.stream.open().every((s) => s.symbol === 'BTC')).toBe(true);385 expect(a[0]).toEqual({ event: 'status', data: { status: 'reconnecting', ts: clock.t } }); // not connected yet: said so386 v.hl.stream.status('connected');387 await settle();388 expect(a.map((e) => e.event)).toEqual(expect.arrayContaining(['status', 'ticker', 'book', 'trades']));389 expect(a.find((e) => e.event === 'ticker').data).toMatchObject({ instrument: BTC, mark: '50000', source: 'venue' });390 // a second listener: no new upstream; it is told the status and the last ticker and book at once391 const offB = d.data.subscribe(inst, (e) => b.push(e));392 expect(v.hl.stream.open()).toHaveLength(3);393 expect(d.data.streamStats.opened - opened).toBe(3);394 expect(d.data.listeners(BTC)).toBe(2);395 expect(b.map((e) => e.event)).toEqual(['status', 'ticker', 'book', 'trades']);396 expect(b[0].data.status).toBe('connected');397 // an upstream event reaches both398 a.length = 0; b.length = 0;399 v.hl.stream.emit({ type: 'ticker', ticker: { instrument: BTC, mark: '50010', index: '50000', mid: '50010', funding: '0.0000125', openInterest: '1', volume24h: '1', change24h: '0', ts: clock.t } });400 v.hl.stream.emit({ type: 'book', book: { instrument: BTC, bids: Array.from({ length: 30 }, (_, k) => [String(50000 - k), '1']), asks: Array.from({ length: 30 }, (_, k) => [String(50020 + k), '1']), ts: clock.t } });401 v.hl.stream.emit({ type: 'trades', trades: [{ instrument: BTC, price: '50010', size: '0.5', side: 'buy', ts: clock.t, id: 't1' }] });402 for (const l of [a, b]) {403 expect(l.map((e) => e.event)).toEqual(['ticker', 'book', 'trades']);404 expect(l[0].data.mark).toBe('50010');405 expect(l[1].data.bids).toHaveLength(20); // depth sent to listeners is capped406 expect(l[2].data.trades[0].id).toBe('t1');407 }408 // stale: both are told; back to connected: the status, then a fresh snapshot read from the venue409 a.length = 0; b.length = 0;410 v.hl.stream.status('stale');411 expect(a).toEqual([{ event: 'status', data: { status: 'stale', ts: clock.t } }]); expect(b).toEqual(a);412 setMarket(v, 50_300); clock.advance(1_000);413 const calls = v.hl.calls.metaAndAssetCtxs;414 v.hl.stream.status('connected');415 await settle();416 for (const l of [a, b]) {417 expect(l.map((e) => e.event).slice(0, 2)).toEqual(['status', 'status']);418 expect(l[1].data.status).toBe('connected');419 expect(l.find((e) => e.event === 'ticker').data.mark).toBe('50300');420 expect(l.find((e) => e.event === 'book').data.bids[0]).toEqual(['50295', '1']);421 }422 expect(v.hl.calls.metaAndAssetCtxs).toBe(calls! + 1); // read again, not served from the cache423 // reconnecting: same424 a.length = 0; b.length = 0;425 v.hl.stream.status('reconnecting'); setMarket(v, 50_400); clock.advance(500); v.hl.stream.status('connected');426 await settle();427 expect(a.map((e) => e.data.status).filter(Boolean)).toEqual(['reconnecting', 'connected']);428 expect(b.find((e) => e.event === 'ticker').data.mark).toBe('50400');429 // one leaves: the upstream stays; the last leaves: closed430 offA();431 expect(v.hl.stream.open()).toHaveLength(3); expect(d.data.listeners(BTC)).toBe(1);432 offB(); offB();433 expect(v.hl.stream.open()).toHaveLength(0);434 expect(d.data.streamStats.closed - closed).toBe(3);435 expect(d.data.listeners(BTC)).toBe(0);436 // a later listener opens a new upstream437 const offC = d.data.subscribe(inst, () => {});438 expect(v.hl.stream.open()).toHaveLength(3);439 offC();440 expect(v.hl.stream.open()).toHaveLength(0);441 });442443 it('GET /stream: Server-Sent Events to two clients over one upstream, closed when both disconnect; GMX is polled', async () => {444 setMarket(v, 50_000); clock.advance(4_000);445 await app.listen({ port: 0, host: '127.0.0.1' });446 const base = `http://127.0.0.1:${app.server.address().port}`;447 const connect = async (instrument: string) => {448 const ac = new AbortController();449 const res = await fetch(`${base}${P}/stream${q({ instrument })}`, { headers: { cookie }, signal: ac.signal });450 const reader = res.body!.getReader(), dec = new TextDecoder();451 let text = '';452 void (async () => { try { for (;;) { const { done, value } = await reader.read(); if (done) break; text += dec.decode(value, { stream: true }); } } catch { /* aborted */ } })();453 const events = () => text.split('\n\n').filter((x) => x.startsWith('event: ')).map((x) => ({ event: x.split('\n')[0]!.slice(7), data: JSON.parse(x.split('\n')[1]!.slice(6)) }));454 const until = async (pred: (e: ReturnType<typeof events>) => boolean) => { for (let k = 0; k < 80 && !pred(events()); k++) await settle(); return events(); };455 return { res, events, until, close: () => ac.abort() };456 };457 expect((await fetch(`${base}${P}/stream${q({ instrument: BTC })}`)).status).toBe(401);458 expect((await fetch(`${base}${P}/stream${q({ instrument: 'hyperliquid:NOPE' })}`, { headers: { cookie } })).status).toBe(404);459 expect((await fetch(`${base}${P}/stream`, { headers: { cookie } })).status).toBe(400);460461 const one = await connect(BTC), two = await connect(BTC);462 expect(one.res.status).toBe(200);463 expect(one.res.headers.get('content-type')).toContain('text/event-stream');464 expect(v.hl.stream.open()).toHaveLength(3); // one upstream for the two clients465 v.hl.stream.status('connected');466 v.hl.stream.emit({ type: 'ticker', ticker: { instrument: BTC, mark: '50020', index: '50000', mid: '50020', funding: '0.0000125', openInterest: '1', volume24h: '1', change24h: '0', ts: clock.t } });467 for (const c of [one, two]) {468 const ev = await c.until((e) => e.some((x) => x.event === 'ticker' && x.data.mark === '50020') && e.some((x) => x.event === 'book'));469 expect(ev.map((x) => x.event), JSON.stringify(ev).slice(0, 600)).toContain('book');470 expect(ev[0]).toMatchObject({ event: 'status' });471 expect(ev.some((x) => x.event === 'status' && x.data.status === 'connected')).toBe(true);472 expect(ev.find((x) => x.event === 'book')!.data).toMatchObject({ instrument: BTC, source: 'venue' });473 }474 v.hl.stream.status('stale');475 for (const c of [one, two]) expect((await c.until((e) => e.some((x) => x.event === 'status' && x.data.status === 'stale'))).some((x) => x.data.status === 'stale')).toBe(true);476 one.close();477 for (let k = 0; k < 80 && d.data.listeners(BTC) !== 1; k++) await settle();478 expect(d.data.listeners(BTC)).toBe(1); expect(v.hl.stream.open()).toHaveLength(3);479 two.close();480 for (let k = 0; k < 80 && v.hl.stream.open().length; k++) await settle();481 expect(v.hl.stream.open()).toHaveLength(0); // upstream closed after the last client left482483 const g = await connect(GMX_BTC);484 const ev = await g.until((e) => e.some((x) => x.event === 'ticker'));485 expect(ev.some((x) => x.event === 'status' && x.data.status === 'connected')).toBe(true);486 expect(ev.find((x) => x.event === 'ticker')!.data).toMatchObject({ instrument: GMX_BTC, source: 'venue' });487 g.close();488 for (let k = 0; k < 80 && d.data.listeners(GMX_BTC); k++) await settle();489 expect(d.data.listeners(GMX_BTC)).toBe(0);490 });491});492493describe('a venue that fails', () => {494 it('degraded then down; data is served with its real time inside the stated bound and refused with 503 beyond it', async () => {495 setMarket(v, 50_000); clock.advance(4_000);496 const fresh = await get(P + '/tickers?venue=hyperliquid');497 expect(fresh.json.ts).toBe(clock.t);498 const t0 = clock.t;499 await get(P + `/market${q({ instrument: BTC })}`);500 v.hl.down = true;501 // 5 s later: the venue fails, the 5-second-old tickers are served — stamped with the time they were read, not now502 clock.advance(5_000);503 const old = await get(P + '/tickers?venue=hyperliquid');504 expect(old.status).toBe(200);505 expect(old.json.ts).toBe(t0);506 expect(old.json.tickers.find((t: any) => t.instrument === BTC).ts).toBe(t0);507 expect((await get(P + '/venues')).json.venues[0]).toMatchObject({ id: 'hyperliquid', status: 'degraded', lastOkAt: t0 });508 // the book is bounded at 10 s, the tickers at 15 s509 clock.advance(6_000);510 const m = await get(P + `/market${q({ instrument: BTC })}`);511 expect(m.status).toBe(200);512 expect(m.json.ticker.ts).toBe(t0);513 expect(m.json.book).toBeNull();514 expect(m.json.errors.book).toMatch(/hyperliquid/);515 clock.advance(5_000);516 const gone = await get(P + '/tickers?venue=hyperliquid');517 expect(gone.status).toBe(503);518 expect(gone.json.error).toBe('venue_unavailable');519 expect((await get(P + `/market${q({ instrument: BTC })}`)).status).toBe(503);520 expect((await get(P + `/candles${q({ instrument: BTC, interval: '5m' })}`)).status).toBe(503);521 const venues = (await get(P + '/venues')).json.venues;522 expect(venues[0]).toMatchObject({ id: 'hyperliquid', status: 'down', lastOkAt: t0 });523 expect(venues[0].lastError).toMatch(/unavailable/);524 expect(venues.slice(1).map((x: any) => x.status)).toEqual(['ok', 'ok']); // the others are not affected525 // all venues at once: the healthy ones answer, the failing one is named526 const all = await get(P + '/tickers');527 expect(all.status).toBe(200);528 expect(all.json.errors.map((e: any) => e.venue)).toEqual(['hyperliquid']);529 expect(all.json.tickers.some((t: any) => t.instrument.startsWith('hyperliquid:'))).toBe(false);530 const cmp = await get(P + '/compare?base=BTC');531 expect(cmp.json.errors.map((e: any) => e.venue)).toEqual(['hyperliquid']);532 expect(cmp.json.rows.some((r: any) => r.venue === 'hyperliquid')).toBe(false);533 // paper orders need a live book: refused, nothing placed534 const acct = (await api('POST', P + '/accounts', { venue: 'hyperliquid', label: 'outage', collateral: '1000' })).json.id;535 const o = await api('POST', P + `/accounts/${acct}/orders`, { instrument: BTC, side: 'buy', type: 'market', size: '0.01', slippageBps: 50 }, { idem: key() });536 expect(o.status).toBe(503);537 // back538 v.hl.down = false; clock.advance(2_000);539 expect((await get(P + '/tickers?venue=hyperliquid')).json.ts).toBe(clock.t);540 expect((await get(P + '/venues')).json.venues[0]).toMatchObject({ status: 'ok', lastOkAt: clock.t, lastError: null });541542 // a book whose own timestamp is old is not a live book, even if the venue answers543 v.hl.bookAgeMs = 20_000; clock.advance(2_000);544 const stale = await get(P + `/market${q({ instrument: BTC })}`);545 expect(stale.json.book).toBeNull();546 expect(stale.json.errors.book).toMatch(/20 s old/);547 const so = await api('POST', P + `/accounts/${acct}/orders`, { instrument: BTC, side: 'buy', type: 'market', size: '0.01', slippageBps: 50 }, { idem: key() });548 expect(so.status).toBe(503); expect(so.json.error).toBe('stale_data');549 v.hl.bookAgeMs = 0;550 expect((await get(P + `/accounts/${acct}`)).json.orders).toEqual([]);551552 // the adapter's own rate-limit budget refusing requests: 503 with its own code; the venue is degraded, never reported down for it553 v.hl.rateLimited = true;554 for (let k = 0; k < 5; k++) { clock.advance(20_000); expect((await get(P + '/tickers?venue=hyperliquid')).json.error).toBe('venue_rate_limited'); }555 expect((await get(P + '/tickers?venue=hyperliquid')).status).toBe(503);556 expect((await get(P + '/venues')).json.venues[0].status).toBe('degraded');557 v.hl.rateLimited = false; clock.advance(2_000);558 expect((await get(P + '/tickers?venue=hyperliquid')).status).toBe(200);559560 // GMX answering 502561 v.gmx.down = true; clock.advance(20_000);562 expect((await get(P + '/tickers?venue=gmx')).status).toBe(503);563 expect((await get(P + '/venues')).json.venues[1].status).not.toBe('ok');564 v.gmx.down = false; clock.advance(2_000);565 expect((await get(P + '/tickers?venue=gmx')).status).toBe(200);566 });567});568