Repository Public
1// Paper accounts and the paper worker against PostgreSQL (database spw_deriv_paper_test, created here if missing) with2// fixture-backed venue adapters and a scripted Hyperliquid BTC market. No network, no signer, no trading endpoint.3import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';4import { D } from '@spw/derivatives';5import { BTC, Clock, T0, fixtureVenues, setMarket, useDatabase, type Venues } from './derivatives-helpers.ts';67let sql: any, mod: typeof import('../src/derivatives/index.ts'), wk: typeof import('../src/derivatives/worker.ts'), pp: typeof import('../src/derivatives/paper.ts');8type Deriv = import('../src/derivatives/index.ts').Derivatives;910beforeAll(async () => {11 await useDatabase('deriv_paper');12 const db = await import('../src/db.ts');13 sql = db.sql;14 await db.migrate();15 await db.migrate(); // the schema is idempotent16 mod = await import('../src/derivatives/index.ts'); wk = await import('../src/derivatives/worker.ts'); pp = await import('../src/derivatives/paper.ts');17});18beforeEach(async () => { await sql`truncate deriv_paper_accounts cascade`; });19afterAll(async () => { await sql?.end(); });2021let seq = 0;22const key = () => `test-key-${++seq}-${Math.random().toString(36).slice(2, 10)}`;23function world(t = T0) {24 const clock = new Clock(t), v = fixtureVenues(clock);25 const build = (): Deriv => mod.createDerivatives({ registry: mod.createRegistry(v.adapters, clock.now), now: clock.now });26 const w = { clock, v, d: build(), restart: () => { w.d = build(); }, step: () => wk.paperStep(w.d.paper),27 /** a few seconds later, with a new market */28 next: (mid: number, o: Parameters<typeof setMarket>[2] = {}) => { clock.advance(5_000); setMarket(v, mid, o); return wk.paperStep(w.d.paper); } };29 setMarket(v, 50_000);30 return w;31}32type World = ReturnType<typeof world>;33const open = async (w: World, collateral = '10000', policy?: Record<string, unknown>) => (await w.d.paper.create({ venue: 'hyperliquid', label: 'test', collateral, ...(policy ? { policy } : {}) } as never)).id;34const order = (w: World, id: string, o: Record<string, unknown>, k = key()) => w.d.paper.order(id, pp.OrderBodySchema.parse({ instrument: BTC, ...o }), k);35const market = (w: World, id: string, side: 'buy' | 'sell', size: string, extra: Record<string, unknown> = {}) => order(w, id, { side, type: 'market', size, slippageBps: 50, tif: 'ioc', ...extra });36const refusal = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? { code: e.code as string, status: e.status as number, message: e.message as string } : null; };37const rows = async (id: string) => {38 const n = async (t: string) => (await sql.unsafe(`select count(*)::int as n from ${t} where account_id = $1`, [id]))[0].n as number;39 return { orders: await n('deriv_orders'), fills: await n('deriv_fills'), funding: await n('deriv_funding'), events: await n('deriv_risk_events'), equity: await n('deriv_equity') };40};41const stored = async (id: string) => (await sql`select state, policy, meta from deriv_paper_accounts where id = ${id}`)[0] as { state: any; policy: any; meta: any };42const events = async (id: string, kind: string) => sql`select * from deriv_risk_events where account_id = ${id} and kind = ${kind} order by id`;43const audits = async (id: string) => (await sql`select type from audit_events where data->>'account' = ${id} order by seq`).map((r: { type: string }) => r.type) as string[];44const LOOSE = { maxLeverage: '20', maxOrderNotional: '100000', maxGrossExposure: '100000', maxPositionNotional: '100000', minFreeCollateral: '0', minLiquidationDistanceBps: '0', maxDailyLoss: '100000', maxTotalLoss: '100000' };4546describe('paper accounts', () => {47 it('a new account is PAPER with a conservative, explicit policy proportional to the collateral', async () => {48 const w = world(), a = await w.d.paper.create(pp.CreateBodySchema.parse({ venue: 'hyperliquid', label: ' first ', collateral: '10000.00' }));49 expect(a).toMatchObject({ venue: 'hyperliquid', network: 'mainnet', kind: 'paper', mode: 'PAPER', label: 'first', initialCollateral: '10000', positions: [], openOrders: [], orders: [], fills: [] });50 expect(a.policy).toEqual({ mode: 'PAPER', allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], halt: null, maxDataAgeMs: 10_000, maxDeviationBps: '200', maxMarkIndexBps: '100', maxLeverage: '3',51 maxOrderNotional: '10000', maxGrossExposure: '30000', maxPositionNotional: '20000', minFreeCollateral: '1000', minLiquidationDistanceBps: '1000', maxDailyLoss: '500', maxTotalLoss: '2000',52 maxOrdersPerMinute: 10, maxOrdersPerDay: 200 });53 expect(a.state).toMatchObject({ equity: '10000', collateral: '10000', freeCollateral: '10000', marginUsed: '0', source: 'paper' });54 expect(a.equity).toHaveLength(1);55 expect(await audits(a.id)).toEqual(['deriv.paper.account_created']);56 const custom = await w.d.paper.create({ venue: 'hyperliquid', label: 'x', collateral: '500', policy: { maxLeverage: '2', allowedInstruments: ['hyperliquid:BTC'] } });57 expect(custom.policy).toMatchObject({ mode: 'PAPER', maxLeverage: '2', allowedInstruments: ['hyperliquid:BTC'], maxOrderNotional: '500' });58 expect(custom.settings.leverage).toBe('2');59 expect((await w.d.paper.list()).map((x) => x.label)).toEqual(['first', 'x']);60 });6162 it('no paper account on dYdX (restriction text) or GMX (no book); a paper account can never be put in a live mode', async () => {63 const w = world();64 const dydx = await refusal(w.d.paper.create({ venue: 'dydx', label: 'x', collateral: '1000' }));65 expect(dydx).toMatchObject({ code: 'venue_restricted', status: 403 });66 expect(dydx!.message).toContain('Canada'); expect(dydx!.message).toContain('geoblocks');67 expect(await refusal(w.d.paper.create({ venue: 'gmx', label: 'x', collateral: '1000' }))).toMatchObject({ code: 'paper_unsupported', status: 422 });68 expect(await refusal(w.d.paper.create({ venue: 'nope', label: 'x', collateral: '1000' }))).toMatchObject({ code: 'unknown_venue', status: 404 });69 expect(await refusal(w.d.paper.create({ venue: 'hyperliquid', label: 'x', collateral: '1000', policy: { mode: 'MANUAL_LIVE' } }))).toMatchObject({ code: 'live_not_available', status: 422 });70 const id = await open(w), p = (await stored(id)).policy;71 expect(await refusal(w.d.paper.setPolicy(id, { ...p, mode: 'MANDATE_LIVE' }))).toMatchObject({ code: 'live_not_available' });72 expect(w.v.dydx.accountCalls).toBe(0);73 expect(w.v.hl.calls.exchange).toBeUndefined();74 });7576 it('long: market buy fills at the ask with the taker fee; close sells at the bid; state, rows and audit agree', async () => {77 const w = world(), id = await open(w);78 const r = await market(w, id, 'buy', '0.1');79 expect(r.order).toMatchObject({ status: 'filled', filled: '0.1', avgFillPrice: '50005', price: '50255', type: 'market', reduceOnly: false }); // worst price: 50005 × 1.005, on the venue grid80 expect(r.fills).toHaveLength(1);81 expect(r.fills[0]).toMatchObject({ price: '50005', size: '0.1', fee: '2.250225', feeAsset: 'USDC', side: 'buy', liquidation: false, closedPnl: null });82 let a = await w.d.paper.get(id);83 expect(a.positions).toHaveLength(1);84 expect(a.positions[0]).toMatchObject({ instrument: BTC, side: 'long', size: '0.1', entryPrice: '50005', markPrice: '50000', leverage: '3', marginMode: 'cross', unrealizedPnl: '-0.5', source: 'paper' });85 expect(a.state).toMatchObject({ collateral: '9997.749775', equity: '9997.249775', source: 'paper' });86 expect(a.orders).toHaveLength(1); expect(a.fills).toHaveLength(1); expect(a.fills[0]).toMatchObject({ price: '50005', fee: '2.250225' });87 expect(a.equity.at(-1)).toMatchObject({ equity: '9997.249775', unrealizedPnl: '-0.5', source: 'paper' });8889 const c = await w.d.paper.close(id, { instrument: BTC, fraction: '1' });90 expect(c).toMatchObject({ worstPrice: '49746', slippageBoundBps: 50, idempotent: false }); // 49995 × 0.995 = 49745.025, rounded up onto the grid: never below the bound91 expect(c.order).toMatchObject({ side: 'sell', type: 'market', reduceOnly: true, status: 'filled', avgFillPrice: '49995' });92 expect(c.fills[0]).toMatchObject({ price: '49995', closedPnl: '-1', fee: '2.249775' });93 a = await w.d.paper.get(id);94 expect(a.positions).toEqual([]);95 expect(a.state).toMatchObject({ collateral: '9994.5', equity: '9994.5', marginUsed: '0' });96 expect(a.totals).toEqual({ realizedPnl: '-1', feesPaid: '4.5', fundingNet: '0', liquidations: 0 });97 expect(await rows(id)).toMatchObject({ orders: 2, fills: 2 });98 expect(await audits(id)).toEqual(['deriv.paper.account_created', 'deriv.paper.order', 'deriv.paper.close']);99 expect(await refusal(w.d.paper.close(id, { instrument: BTC, fraction: '1' }))).toMatchObject({ code: 'no_position', status: 422 });100 });101102 it('short: sells at the bid, gains when the mark falls, a partial close then the rest', async () => {103 const w = world(), id = await open(w);104 const r = await market(w, id, 'sell', '0.1');105 expect(r.fills[0]).toMatchObject({ side: 'sell', price: '49995', fee: '2.249775' });106 await w.next(49_000);107 let a = await w.d.paper.get(id);108 expect(a.positions[0]).toMatchObject({ side: 'short', size: '0.1', entryPrice: '49995', markPrice: '49000', unrealizedPnl: '99.5' });109 const half = await w.d.paper.close(id, { instrument: BTC, fraction: '0.5' });110 expect(half.order).toMatchObject({ side: 'buy', size: '0.05', status: 'filled', reduceOnly: true });111 expect(half.fills[0]).toMatchObject({ price: '49005', closedPnl: '49.5' });112 const rest = await w.d.paper.close(id, { instrument: BTC, fraction: '1' });113 expect(rest.fills[0]).toMatchObject({ size: '0.05', closedPnl: '49.5' });114 a = await w.d.paper.get(id);115 expect(a.positions).toEqual([]);116 // 10000 + 99 − fees (4999.5 + 2450.25 + 2450.25) × 0.00045117 expect(a.state.collateral).toBe(D.str(D.sub(D.from('10099'), D.mul(D.from('9900'), D.from('0.00045')))));118 expect(a.totals.realizedPnl).toBe('99');119 });120121 it('partial fill on a thin book: what the book cannot give within the price bound is cancelled', async () => {122 const w = world(), id = await open(w);123 setMarket(w.v, 50_000, { asks: [['50005', '0.01'], ['50900', '5']] });124 const r = await market(w, id, 'buy', '0.05');125 expect(r.order).toMatchObject({ status: 'cancelled', filled: '0.01', avgFillPrice: '50005' });126 expect(r.order.rejectReason).toMatch(/no liquidity within the price bound/);127 expect(r.fills).toHaveLength(1);128 expect((await w.d.paper.get(id)).positions[0]).toMatchObject({ size: '0.01' });129 });130131 it('preview runs the same checks and the same simulation and stores nothing', async () => {132 const w = world(), id = await open(w), before = await stored(id), n = await rows(id);133 const p = await w.d.paper.preview(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }));134 expect(p.risk).toEqual({ ok: true });135 expect(p.request).toMatchObject({ price: '50255', clientId: 'preview' });136 expect(p.estimate).toMatchObject({ notional: '5000.5', fee: '2.250225', avgFillPrice: '50005', slippageBps: '1', filledSize: '0.1', restingSize: '0', status: 'filled', leverage: '3', source: 'estimate',137 initialMargin: '1666.833333333333333334' });138 // the whole account backs 0.1 BTC: no price liquidates it — said as such, and labelled an estimate139 expect(p.estimate.liquidationPrice).toMatchObject({ value: null, source: 'estimate' });140 expect(D.str(D.add(D.from(p.estimate.freeCollateralAfter), D.from('1666.666666666666666667')))).toBe('9997.249775'); // equity − margin at the mark141 // a resting limit: nothing fills, the estimate says so142 const lim = await w.d.paper.preview(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'limit', size: '0.1', price: '49900' }));143 expect(lim.estimate).toMatchObject({ avgFillPrice: null, slippageBps: null, filledSize: '0', restingSize: '0.1', status: 'open', notional: '4990', fee: '0.7485' });144 // refused orders are previewed with their code145 const big = await w.d.paper.preview(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'market', size: '0.25', slippageBps: 50 }));146 expect(big.risk).toMatchObject({ ok: false, code: 'order_notional_exceeded' });147 const noPrice = await w.d.paper.preview(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'market', size: '0.1' }));148 expect(noPrice.risk).toMatchObject({ ok: false, code: 'price_required' });149 expect(await stored(id)).toEqual(before);150 expect(await rows(id)).toEqual(n);151 });152153 it('risk refusals carry the engine code, are recorded as risk events and place nothing', async () => {154 const w = world(), id = await open(w);155 const cases: [Record<string, unknown>, string][] = [156 [{ side: 'buy', type: 'market', size: '1', slippageBps: 50 }, 'leverage_exceeded'],157 [{ side: 'buy', type: 'market', size: '0.25', slippageBps: 50 }, 'order_notional_exceeded'],158 [{ side: 'buy', type: 'limit', size: '0.1', price: '45000' }, 'price_deviation'],159 [{ side: 'buy', type: 'limit', size: '0.100001', price: '49900' }, 'size_not_on_lot'],160 [{ side: 'buy', type: 'limit', size: '0.1', price: '49900.5' }, 'price_not_on_tick'], // Hyperliquid: 5 significant figures161 [{ side: 'buy', type: 'limit', size: '0.0001', price: '49900' }, 'below_min_notional'],162 [{ side: 'buy', type: 'market', size: '0.1' }, 'price_required'],163 [{ side: 'sell', type: 'market', size: '0.1', slippageBps: 50, reduceOnly: true }, 'reduce_only_would_increase'],164 [{ side: 'buy', type: 'stop_market', size: '0.1', price: '50500' }, 'trigger_required'],165 [{ side: 'buy', type: 'market', size: '0.1', slippageBps: 50, leverage: '5' }, 'leverage_exceeded'],166 [{ side: 'buy', type: 'market', size: '0.1', slippageBps: 50, leverage: '2.5' }, 'leverage_exceeded'],167 [{ instrument: 'hyperliquid:SOL', side: 'buy', type: 'market', size: '1', slippageBps: 50 }, 'instrument_not_allowed'],168 ];169 setMarket(w.v, 50_000);170 for (const [o, code] of cases) {171 if (o.instrument === 'hyperliquid:SOL') { expect(await refusal(order(w, id, o)), JSON.stringify(o)).not.toBeNull(); continue; } // no recorded SOL book: refused before the engine172 expect(await refusal(order(w, id, o)), JSON.stringify(o)).toMatchObject({ code, status: 422 });173 }174 expect(await rows(id)).toMatchObject({ orders: 0, fills: 0 });175 const ev = await events(id, 'order_refused');176 expect(ev.map((e: any) => e.code)).toEqual(cases.filter(([o]) => !o.instrument).map(([, c]) => c));177 expect((await stored(id)).state.orders).toEqual([]);178 // the venue stops answering: an order needs a ticker and a book read within the last second — an older cached one is not used179 w.v.hl.down = true; w.clock.advance(2_000);180 expect(await refusal(market(w, id, 'buy', '0.1'))).toMatchObject({ code: 'venue_unavailable', status: 503 });181 expect(await rows(id)).toMatchObject({ orders: 0, fills: 0 });182 w.v.hl.down = false; w.clock.advance(2_000);183 // an instrument on another venue, an unknown one184 expect(await refusal(order(w, id, { instrument: 'dydx:BTC-USD', side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }))).toMatchObject({ code: 'instrument_mismatch', status: 422 });185 expect(await refusal(order(w, id, { instrument: 'hyperliquid:NOPE', side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }))).toMatchObject({ code: 'unknown_instrument', status: 404 });186 });187188 it('idempotent submission: a repeated key returns the first result and places nothing, even when sent twice at once', async () => {189 const w = world(), id = await open(w), k = key();190 const first = await market(w, id, 'buy', '0.1', {}), again = await w.d.paper.order(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }), first.order.clientId);191 expect(first.idempotent).toBe(false); expect(again.idempotent).toBe(true);192 expect(again.order).toEqual(first.order); expect(again.fills).toEqual(first.fills);193 // the market moved: the first result is still what the key returns194 await w.next(51_000);195 expect((await w.d.paper.order(id, pp.OrderBodySchema.parse({ instrument: BTC, side: 'sell', type: 'market', size: '0.05', slippageBps: 50 }), first.order.clientId)).order).toEqual(first.order);196 await market(w, id, 'buy', '0.02');197 const pair = await Promise.all([order(w, id, { side: 'buy', type: 'market', size: '0.03', slippageBps: 50 }, k), order(w, id, { side: 'buy', type: 'market', size: '0.03', slippageBps: 50 }, k)]);198 expect(pair.map((p) => p.idempotent).sort()).toEqual([false, true]);199 expect(pair[0].order.venueOrderId).toBe(pair[1].order.venueOrderId);200 const a = await w.d.paper.get(id);201 expect(a.positions[0]!.size).toBe('0.15'); // 0.1 + 0.02 + 0.03, each once202 expect(await rows(id)).toMatchObject({ orders: 3, fills: 3 });203 expect((await stored(id)).state.orders).toHaveLength(3);204 });205206 it('cancel and reset', async () => {207 const w = world(), id = await open(w);208 const lim = await order(w, id, { side: 'buy', type: 'limit', size: '0.1', price: '49900' });209 expect(lim.order.status).toBe('open'); expect(lim.fills).toEqual([]);210 expect((await w.d.paper.get(id)).openOrders).toHaveLength(1);211 const c = await w.d.paper.cancel(id, lim.order.clientId);212 expect(c.order).toMatchObject({ status: 'cancelled', clientId: lim.order.clientId });213 expect((await w.d.paper.cancel(id, lim.order.clientId)).order.status).toBe('cancelled'); // again: unchanged214 expect(await refusal(w.d.paper.cancel(id, 'never-seen'))).toMatchObject({ code: 'unknown_order', status: 404 });215 expect((await sql`select status from deriv_orders where account_id = ${id}`)[0].status).toBe('cancelled');216 await market(w, id, 'buy', '0.1');217 const r = await w.d.paper.reset(id);218 expect(r).toMatchObject({ positions: [], openOrders: [], orders: [], fills: [], funding: [] });219 expect(r.state).toMatchObject({ collateral: '10000', equity: '10000' });220 expect(await rows(id)).toMatchObject({ orders: 0, fills: 0, funding: 0, equity: 1 });221 expect((await events(id, 'reset'))).toHaveLength(1);222 expect((await audits(id)).slice(-1)).toEqual(['deriv.paper.reset']);223 expect(await refusal(w.d.paper.reset('00000000-0000-4000-8000-000000000000'))).toMatchObject({ code: 'unknown_account', status: 404 });224 });225});226227describe('paper worker', () => {228 it('a resting limit is filled later by the worker, as a maker, for the crossing depth only; the same inputs twice change nothing', async () => {229 const w = world(), id = await open(w);230 const lim = await order(w, id, { side: 'buy', type: 'limit', size: '0.1', price: '49900' });231 expect(lim.order.status).toBe('open');232 expect(await w.next(50_000)).toMatchObject({ accounts: 1, fed: 1, fills: 0 });233 // the book comes down through the limit with 0.04 at 49890: 0.04 fills at the order's own price234 expect(await w.next(49_895, { asks: [['49890', '0.04'], ['49910', '3']], bids: [['49880', '1']] })).toMatchObject({ fills: 1 });235 let a = await w.d.paper.get(id);236 expect(a.openOrders[0]).toMatchObject({ status: 'partially_filled', filled: '0.04', avgFillPrice: '49900' });237 expect(a.fills[0]).toMatchObject({ price: '49900', size: '0.04', fee: '0.2994', side: 'buy' }); // maker: 0.04 × 49900 × 0.00015238 // the loop again over the very same inputs: nothing239 const snap = await stored(id), n = await rows(id);240 expect(await w.step()).toMatchObject({ fed: 1, fills: 0, funding: 0 });241 expect(await w.step()).toMatchObject({ fills: 0 });242 expect(await stored(id)).toEqual(snap); expect(await rows(id)).toEqual(n);243 // the rest244 expect(await w.next(49_885, { asks: [['49880', '1']], bids: [['49870', '1']] })).toMatchObject({ fills: 1 });245 a = await w.d.paper.get(id);246 expect(a.openOrders).toEqual([]);247 expect(a.orders[0]).toMatchObject({ status: 'filled', filled: '0.1', avgFillPrice: '49900' });248 expect(a.positions[0]).toMatchObject({ side: 'long', size: '0.1', entryPrice: '49900', markPrice: '49885' });249 expect(await rows(id)).toMatchObject({ orders: 1, fills: 2 });250 expect((await audits(id)).filter((t) => t === 'deriv.paper.fill')).toHaveLength(2);251 });252253 it('stop and take-profit orders trigger on the mark and execute on the next book', async () => {254 const w = world(), id = await open(w);255 await market(w, id, 'buy', '0.1');256 const stop = await order(w, id, { side: 'sell', type: 'stop_market', size: '0.1', triggerPrice: '49500', price: '49000', reduceOnly: true });257 const tp = await order(w, id, { side: 'sell', type: 'take_profit_market', size: '0.1', triggerPrice: '50500', price: '50000', reduceOnly: true });258 expect(stop.order.status).toBe('pending'); expect(tp.order.status).toBe('pending');259 expect(await w.next(50_300)).toMatchObject({ fills: 0 });260 // the mark reaches the take-profit: it sells at the bid; the position is gone261 expect(await w.next(50_600)).toMatchObject({ fills: 1 });262 let a = await w.d.paper.get(id);263 expect(a.positions).toEqual([]);264 expect(a.orders.find((o) => o.clientId === tp.order.clientId)).toMatchObject({ status: 'filled', avgFillPrice: '50595' });265 expect(a.fills[0]).toMatchObject({ price: '50595', closedPnl: '59', side: 'sell' });266 expect(a.openOrders.map((o) => o.clientId)).toEqual([stop.order.clientId]);267 // the stop fires later with nothing left to reduce: cancelled, never a new short268 await w.next(49_400);269 a = await w.d.paper.get(id);270 expect(a.orders.find((o) => o.clientId === stop.order.clientId)).toMatchObject({ status: 'cancelled' });271 expect(a.positions).toEqual([]); expect(a.openOrders).toEqual([]);272273 // a stop that protects a long274 const id2 = await open(w);275 setMarket(w.v, 50_000); w.clock.advance(5_000);276 await market(w, id2, 'buy', '0.1');277 const s2 = await order(w, id2, { side: 'sell', type: 'stop_market', size: '0.1', triggerPrice: '49500', price: '49000', reduceOnly: true });278 await w.next(49_600);279 expect((await w.d.paper.get(id2)).openOrders[0]).toMatchObject({ status: 'pending' });280 await w.next(49_400);281 const b = await w.d.paper.get(id2);282 expect(b.orders.find((o) => o.clientId === s2.order.clientId)).toMatchObject({ status: 'filled', avgFillPrice: '49395' });283 expect(b.positions).toEqual([]);284 expect(b.fills[0]).toMatchObject({ price: '49395', closedPnl: '-61' });285 });286287 it('funding is applied once per funding time, with the rate the venue published for it; restart-safe', async () => {288 const w = world(), id = await open(w), H = 3_600_000, hour = Math.floor(T0 / H) * H;289 await market(w, id, 'buy', '0.1');290 expect((await stored(id)).meta.fundingCursor).toEqual({ [BTC]: hour }); // the funding time already passed is not owed291 w.v.hl.funding = [];292 expect(await w.next(50_000)).toMatchObject({ funding: 0 });293 // the next funding time passes; the venue publishes its rate 27 ms after the hour294 w.clock.t = hour + H + 10_000; w.v.hl.funding = [{ ts: hour + H + 27, rate: '0.0000125' }]; setMarket(w.v, 50_000);295 expect(await w.step()).toMatchObject({ funding: 1 });296 let a = await w.d.paper.get(id);297 expect(a.funding).toEqual([{ instrument: BTC, amount: '-0.0625', rate: '0.0000125', size: '0.1', ts: hour + H }]); // a long pays 0.1 × 50000 × 0.0000125298 expect(a.totals.fundingNet).toBe('-0.0625');299 expect(a.positions[0]!.fundingPaid).toBe('0.0625');300 // again, then after a restart: still once301 w.clock.advance(5_000); expect(await w.step()).toMatchObject({ funding: 0 });302 w.restart(); w.clock.advance(5_000); expect(await w.step()).toMatchObject({ funding: 0 });303 expect(await rows(id)).toMatchObject({ funding: 1 });304 expect((await stored(id)).state.funding).toHaveLength(1);305 // the worker was down for three funding times: each is applied once, with its own rate306 w.clock.t = hour + 4 * H + 10_000; setMarket(w.v, 50_000);307 w.v.hl.funding = [1, 2, 3, 4].map((k) => ({ ts: hour + k * H + 40, rate: `0.00001${k}` }));308 expect(await w.step()).toMatchObject({ funding: 3 });309 a = await w.d.paper.get(id);310 expect(a.funding.map((f) => [f.ts, f.rate])).toEqual([[hour + 4 * H, '0.000014'], [hour + 3 * H, '0.000013'], [hour + 2 * H, '0.000012'], [hour + H, '0.0000125']]);311 // a rate the venue has not published yet is waited for, then skipped with a risk event — never invented312 w.clock.t = hour + 5 * H + 10_000; setMarket(w.v, 50_000);313 expect(await w.step()).toMatchObject({ funding: 0 });314 expect((await stored(id)).meta.fundingCursor[BTC]).toBe(hour + 4 * H);315 w.clock.t = hour + 5 * H + 16 * 60_000; setMarket(w.v, 50_000);316 expect(await w.step()).toMatchObject({ funding: 0 });317 expect((await stored(id)).meta.fundingCursor[BTC]).toBe(hour + 5 * H);318 expect((await events(id, 'funding_skipped')).map((e: any) => e.code)).toEqual(['funding_rate_missing']);319 expect(await rows(id)).toMatchObject({ funding: 4 });320 // a closed position owes nothing more321 await w.d.paper.close(id, { instrument: BTC, fraction: '1' });322 expect((await stored(id)).meta.fundingCursor).toEqual({});323 });324325 it('margin warning, then simulated liquidation → risk events, positions closed at the mark, orders cancelled', async () => {326 const w = world(), id = await open(w, '1000', LOOSE);327 const r = await market(w, id, 'buy', '0.3', { leverage: '20' });328 expect(r.order.status).toBe('filled');329 const resting = await order(w, id, { side: 'sell', type: 'limit', size: '0.1', price: '50900', reduceOnly: true });330 expect(resting.order.status).toBe('open');331 expect(await w.next(49_000)).toMatchObject({ liquidations: 0 });332 expect(await events(id, 'margin_warning')).toHaveLength(0);333 // equity 331.75 against a maintenance margin of 286.8: inside the warning zone, once334 expect(await w.next(47_800)).toMatchObject({ liquidations: 0 });335 await w.next(47_810);336 const warn = await events(id, 'margin_warning');337 expect(warn).toHaveLength(1);338 expect(warn[0].data).toEqual({ equity: '331.749325', maintenanceMargin: '286.8' });339 // equity 271.75 < 285.6: liquidated at the mark340 expect(await w.next(47_600)).toMatchObject({ liquidations: 1, fills: 1 });341 const a = await w.d.paper.get(id);342 expect(a.positions).toEqual([]); expect(a.openOrders).toEqual([]);343 expect(a.fills[0]).toMatchObject({ liquidation: true, price: '47600', size: '0.3', side: 'sell', closedPnl: '-721.5', orderId: null });344 expect(a.state.collateral).toBe('271.749325');345 expect(a.totals.liquidations).toBe(1);346 expect(a.orders.find((o) => o.clientId === resting.order.clientId)).toMatchObject({ status: 'cancelled', rejectReason: 'account liquidated (simulated)' });347 const liq = await events(id, 'liquidation');348 expect(liq).toHaveLength(1);349 expect(liq[0]).toMatchObject({ code: 'liquidated' });350 expect(liq[0].data.collateralAfter).toBe('271.749325');351 expect(await audits(id)).toContain('deriv.paper.liquidation');352 // the account is flat: the worker has nothing more to do with it, and running again changes nothing353 expect(await w.next(47_000)).toMatchObject({ accounts: 0 });354 expect(await events(id, 'liquidation')).toHaveLength(1);355 expect((await w.d.paper.get(id)).equity.at(-1)).toMatchObject({ equity: '271.749325' });356 });357358 it('loss limit → halt: resting orders withdrawn, a new order refused, a reduce-only close accepted; the halt lasts until the policy is replaced', async () => {359 const w = world(), id = await open(w);360 await market(w, id, 'buy', '0.19');361 const lim = await order(w, id, { side: 'buy', type: 'limit', size: '0.01', price: '49500' });362 const tp = await order(w, id, { side: 'sell', type: 'limit', size: '0.19', price: '50900', reduceOnly: true });363 expect(await w.next(49_990, { asks: [['49995', '1']], bids: [['49985', '1']] })).toMatchObject({ halts: 0 });364 // −0.19 × (50005 − 47000) = −570.95 and the fee: beyond the daily limit of 500365 expect(await w.next(47_000, { asks: [['49600', '1']], bids: [['46990', '1']] })).toMatchObject({ halts: 1, liquidations: 0 });366 const s = await stored(id);367 expect(s.policy.halt).toMatch(/^daily loss limit reached: PnL -575\.\d+ against a limit of −500/);368 const ev = await events(id, 'loss_limit');369 expect(ev).toHaveLength(1);370 expect(ev[0]).toMatchObject({ code: 'daily_loss_exceeded' });371 expect(ev[0].data.cancelledOrders).toEqual([lim.order.clientId]);372 let a = await w.d.paper.get(id);373 expect(a.orders.find((o) => o.clientId === lim.order.clientId)).toMatchObject({ status: 'cancelled', rejectReason: 'cancelled: trading halted by a loss limit' });374 expect(a.openOrders.map((o) => o.clientId)).toEqual([tp.order.clientId]); // the reduce-only order stays375 expect(await audits(id)).toContain('deriv.paper.halt');376 // once: the next steps record nothing more377 await w.next(46_990, { asks: [['47000', '1']], bids: [['46980', '1']] });378 expect(await events(id, 'loss_limit')).toHaveLength(1);379 // new risk is refused …380 expect(await refusal(market(w, id, 'buy', '0.01'))).toMatchObject({ code: 'halted', status: 422 });381 expect(await refusal(market(w, id, 'sell', '0.3'))).toMatchObject({ code: 'halted' }); // a flip is new risk too382 // … closing is not383 const c = await w.d.paper.close(id, { instrument: BTC, fraction: '1' });384 expect(c.order).toMatchObject({ status: 'filled', reduceOnly: true, avgFillPrice: '46980' });385 a = await w.d.paper.get(id);386 expect(a.positions).toEqual([]);387 expect(a.policy.halt).not.toBeNull();388 // the owner lifts the halt: the daily loss itself still refuses new exposure today389 const p = await w.d.paper.setPolicy(id, { ...a.policy, halt: null });390 expect(p.policy.halt).toBeNull();391 expect(await refusal(market(w, id, 'buy', '0.01'))).toMatchObject({ code: 'daily_loss_exceeded', status: 422 });392 const raised = await w.d.paper.setPolicy(id, { ...a.policy, halt: null, maxDailyLoss: '5000' });393 expect(raised.policy.maxDailyLoss).toBe('5000');394 expect((await market(w, id, 'buy', '0.01')).order.status).toBe('filled');395 });396397 it('restart mid-flow: a new process between every step ends in exactly the same state', async () => {398 const H = 3_600_000, hour = Math.floor(T0 / H) * H;399 const run = async (restart: boolean) => {400 const w = world(), id = await open(w), hop = () => { if (restart) w.restart(); };401 w.v.hl.funding = [{ ts: hour + H + 31, rate: '0.0000125' }];402 const keys = ['flow-key-0001', 'flow-key-0002', 'flow-key-0003'];403 await order(w, id, { side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }, keys[0]); hop();404 await order(w, id, { side: 'buy', type: 'limit', size: '0.05', price: '49900' }, keys[1]); hop();405 await order(w, id, { side: 'sell', type: 'stop_market', size: '0.1', triggerPrice: '49000', price: '48600', reduceOnly: true }, keys[2]); hop();406 await w.next(49_895, { asks: [['49890', '0.02'], ['49920', '1']], bids: [['49885', '1']] }); hop();407 await order(w, id, { side: 'buy', type: 'market', size: '0.1', slippageBps: 50 }, keys[0]); hop(); // a replayed key408 w.clock.t = hour + H + 2_000; setMarket(w.v, 49_900); await w.step(); hop();409 await w.next(48_900, { asks: [['48905', '1']], bids: [['48895', '1']] }); hop();410 await w.next(48_950); hop();411 const s = await stored(id);412 s.state.account = 'x';413 return { s, n: await rows(id), view: await w.d.paper.get(id) };414 };415 const a = await run(false), b = await run(true);416 expect(b.s).toEqual(a.s);417 expect(b.n).toEqual(a.n);418 expect(b.view.fills.map((f) => [f.id, f.price, f.size, f.fee, f.ts])).toEqual(a.view.fills.map((f) => [f.id, f.price, f.size, f.fee, f.ts]));419 expect(b.view.funding).toEqual(a.view.funding);420 // and the flow did what it was meant to: limit filled in two parts, one funding payment, the stop sold 0.1421 expect(a.n).toMatchObject({ orders: 3, fills: 4, funding: 1 });422 expect(a.view.positions[0]).toMatchObject({ side: 'long', size: '0.05' });423 expect(a.view.orders.map((o) => o.status).sort()).toEqual(['filled', 'filled', 'filled']);424 });425426 it('a venue that stops answering: nothing is fed, nothing is invented', async () => {427 const w = world(), id = await open(w);428 await market(w, id, 'buy', '0.1');429 const snap = await stored(id);430 w.v.hl.down = true;431 w.clock.advance(20_000);432 const s = await w.step();433 expect(s).toMatchObject({ accounts: 1, fed: 0, fills: 0 });434 expect(s.errors[0]).toMatch(/hyperliquid/);435 expect(await stored(id)).toEqual(snap);436 w.v.hl.down = false;437 expect(await w.next(50_100)).toMatchObject({ fed: 1 });438 expect((await w.d.paper.get(id)).positions[0]!.markPrice).toBe('50100');439 });440});441