SPB Git

Repository Public

derivatives-paper.test.ts 32.4 KB 441 lines typescript

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
Files main
Menu

Theme