SPB Git

Repository Public

paper.test.ts 30.3 KB 470 lines typescript

1import { afterEach, describe, expect, it, vi } from 'vitest';2import { AccountStateSchema, PaperAccount, PaperStateSchema, PositionSchema, type Book, type Fill, type OrderRequest, type PaperConfig, type Ticker } from '../src/index.ts';34const ID = 'hyperliquid:BTC';5const T0 = 1_700_000_000_000;6/** round numbers so every expected value below can be checked by hand: taker 0.1 %, maker 0.05 %, 10x, maintenance 5 % */7const CONFIG: Partial<PaperConfig> = { takerFeeRate: '0.001', makerFeeRate: '0.0005', leverage: '10', maintenanceMarginFraction: '0.05' };89const book = (ts: number, bids: [string, string][], asks: [string, string][], instrument = ID): Book => ({ instrument, bids, asks, ts });10const ticker = (ts: number, mark: string, instrument = ID): Ticker => ({ instrument, mark, index: null, mid: null, funding: null, openInterest: null, volume24h: null, change24h: null, ts });11let n = 0;12const order = (o: Partial<OrderRequest> = {}): OrderRequest => ({ instrument: ID, side: 'buy', type: 'market', size: '1', price: '101', triggerPrice: null, reduceOnly: false, tif: 'ioc', clientId: `c-${++n}`, ...o });13const account = (collateral = '10000', config: Partial<PaperConfig> = {}): PaperAccount => PaperAccount.create({ venue: 'hyperliquid', account: 'paper-1', collateral, config: { ...CONFIG, ...config } });1415afterEach(() => { vi.restoreAllMocks(); });1617describe('paper: market orders', () => {18  it('long: walks the book, pays the taker fee, marks to market, closes with a profit', () => {19    const a = account();20    // buy 1 with worst price 101: 0.4 @ 100 + 0.6 @ 100.5 = 40 + 60.3 = 100.3 ; fee 0.1 % = 0.100321    const r = a.submit(order(), book(T0, [['99.5', '5']], [['100', '0.4'], ['100.5', '0.6'], ['102', '5']]), ticker(T0, '100'), T0);22    expect(r.order!.status).toBe('filled');23    expect(r.order!.filled).toBe('1');24    expect(r.order!.avgFillPrice).toBe('100.3');25    expect(r.fills.map((f) => [f.price, f.size, f.fee, f.closedPnl, f.liquidation])).toEqual([['100', '0.4', '0.04', null, false], ['100.5', '0.6', '0.0603', null, false]]);26    expect(a.state.collateral).toBe('9999.8997');                     // 10 000 − 0.100327    expect(a.state.positions).toEqual([{ instrument: ID, side: 'long', size: '1', entryPrice: '100.3', markPrice: '100', realizedPnl: '0', fundingPaid: '0' }]);2829    // mark 110: unrealized = 1 · (110 − 100.3) = 9.7 ; equity = 9 999.8997 + 9.7 ; margin used = 110 / 10 = 1130    a.onTicker(ticker(T0 + 1000, '110'), T0 + 1000);31    const st = a.accountState(T0 + 1000);32    expect(AccountStateSchema.parse(st)).toEqual(st);33    expect([st.equity, st.collateral, st.marginUsed, st.freeCollateral, st.maintenanceMargin, st.source]).toEqual(['10009.5997', '9999.8997', '11', '9998.5997', '5.5', 'paper']);34    const [p] = a.positions();35    expect(PositionSchema.parse(p)).toEqual(p);36    expect([p!.unrealizedPnl, p!.leverage, p!.marginMode, p!.margin, p!.source]).toEqual(['9.7', '10', 'cross', '11', 'paper']);37    expect(p!.liquidationPrice).toBeNull();                           // collateral covers the whole notional3839    // close: sell 1 at the bid 110 → realised 9.7 ; fee 0.11 ; collateral = 9 999.8997 + 9.7 − 0.11 = 10 009.489740    const c = a.submit(order({ side: 'sell', price: '109', reduceOnly: true }), book(T0 + 2000, [['110', '2']], [['110.5', '2']]), ticker(T0 + 2000, '110'), T0 + 2000);41    expect(c.order!.status).toBe('filled');42    expect(c.fills[0]!.closedPnl).toBe('9.7');43    expect(a.state.positions).toEqual([]);44    expect(a.state.collateral).toBe('10009.4897');45    expect([a.state.realizedPnl, a.state.feesPaid]).toEqual(['9.7', '0.2103']);46    expect(a.accountState(T0 + 2000).equity).toBe('10009.4897');47  });4849  it('short: profits when the price falls', () => {50    const a = account();51    // sell 2 @ 100 → short 2 ; fee 0.252    a.submit(order({ side: 'sell', size: '2', price: '99' }), book(T0, [['100', '5']], [['100.5', '5']]), ticker(T0, '100'), T0);53    expect(a.state.positions[0]).toMatchObject({ side: 'short', size: '2', entryPrice: '100' });54    a.onTicker(ticker(T0 + 1, '90'), T0 + 1);55    expect(a.positions()[0]!.unrealizedPnl).toBe('20');               // 2 · (100 − 90)56    // buy 2 back @ 90 → realised 20 ; fee 0.18 ; collateral = 10 000 − 0.2 + 20 − 0.18 = 10 019.6257    const c = a.submit(order({ side: 'buy', size: '2', price: '91' }), book(T0 + 2, [['89.5', '5']], [['90', '5']]), ticker(T0 + 2, '90'), T0 + 2);58    expect(c.fills[0]!.closedPnl).toBe('20');59    expect(a.state.collateral).toBe('10019.62');60    expect(a.state.positions).toEqual([]);61  });6263  it('partial fill: stops at the worst price and cancels the remainder', () => {64    const a = account();65    const r = a.submit(order({ price: '100.5' }), book(T0, [], [['100', '0.4'], ['101', '5']]), ticker(T0, '100'), T0);66    expect([r.order!.status, r.order!.filled, r.order!.avgFillPrice]).toEqual(['cancelled', '0.4', '100']);67    expect(r.order!.rejectReason).toMatch(/remainder cancelled/);68    expect(a.state.positions[0]!.size).toBe('0.4');69    expect(a.openOrders()).toEqual([]);70    // book thinner than the order, everything inside the bound: fills what is there71    const b = account();72    const r2 = b.submit(order({ size: '3' }), book(T0, [], [['100', '1'], ['100.5', '1']]), ticker(T0, '100'), T0);73    expect([r2.order!.status, r2.order!.filled, r2.fills.length]).toEqual(['cancelled', '2', 2]);74    // nothing within the bound: no fill, no position75    const c = account();76    const r3 = c.submit(order({ price: '99' }), book(T0, [], [['100', '1']]), ticker(T0, '100'), T0);77    expect([r3.order!.status, r3.order!.filled, r3.fills.length, c.state.positions.length, c.state.collateral]).toEqual(['cancelled', '0', 0, 0, '10000']);78  });7980  it('flip: long 1 then sell 3 leaves short 2 and realises the closed part', () => {81    const a = account();82    a.submit(order(), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);83    const r = a.submit(order({ side: 'sell', size: '3', price: '94' }), book(T0 + 1, [['95', '5']], []), ticker(T0 + 1, '95'), T0 + 1);84    expect(r.fills[0]!.closedPnl).toBe('-5');                         // 1 · (95 − 100)85    expect(a.state.positions[0]).toMatchObject({ side: 'short', size: '2', entryPrice: '95', realizedPnl: '0' });86    expect(a.state.realizedPnl).toBe('-5');87    // collateral = 10 000 − 0.1 (fee 1 · 100) − 5 − 0.285 (fee 3 · 95) = 9 994.61588    expect(a.state.collateral).toBe('9994.615');89  });90});9192describe('paper: limit orders', () => {93  it('fills the crossing part as a taker, rests the remainder, fills it later as a maker at its own price', () => {94    const a = account();95    const req = order({ type: 'limit', tif: 'gtc', price: '100' });96    const r = a.submit(req, book(T0, [['99', '1']], [['100', '0.4'], ['101', '5']]), ticker(T0, '100'), T0);97    expect([r.order!.status, r.order!.filled]).toEqual(['partially_filled', '0.4']);98    expect(a.openOrders().map((o) => o.clientId)).toEqual([req.clientId]);99    // a book that does not cross leaves it alone100    expect(a.onBook(book(T0 + 1, [['99', '1']], [['100.5', '3']]), T0 + 1).fills).toEqual([]);101    // asks at 99.5 cross the bid at 100: filled at 100 (the order's price) for the depth available, maker fee 0.05 %102    const f1 = a.onBook(book(T0 + 2, [['99', '1']], [['99.5', '0.3'], ['100.5', '3']]), T0 + 2);103    expect(f1.fills.map((f) => [f.price, f.size, f.fee])).toEqual([['100', '0.3', '0.015']]);104    expect(a.order(req.clientId)!.status).toBe('partially_filled');105    const f2 = a.onBook(book(T0 + 3, [], [['100', '10']]), T0 + 3);106    expect(f2.fills.map((f) => [f.price, f.size])).toEqual([['100', '0.3']]);107    expect([a.order(req.clientId)!.status, a.order(req.clientId)!.filled, a.order(req.clientId)!.avgFillPrice]).toEqual(['filled', '1', '100']);108    expect(a.state.positions[0]).toMatchObject({ size: '1', entryPrice: '100' });109    // fees: 0.4 · 100 · 0.001 + 0.6 · 100 · 0.0005 = 0.04 + 0.03110    expect(a.state.feesPaid).toBe('0.07');111    expect(a.openOrders()).toEqual([]);112  });113114  it('a resting sell is filled by bids that reach it; two resting orders share the depth, oldest first', () => {115    const a = account();116    const s1 = order({ side: 'sell', type: 'limit', tif: 'gtc', price: '105', size: '1' }), s2 = order({ side: 'sell', type: 'limit', tif: 'gtc', price: '105', size: '1' });117    a.submit(s1, book(T0, [['100', '5']], [['101', '5']]), ticker(T0, '100'), T0);118    a.submit(s2, book(T0, [['100', '5']], [['101', '5']]), ticker(T0, '100'), T0);119    expect(a.openOrders().length).toBe(2);120    const r = a.onBook(book(T0 + 1, [['106', '1.5'], ['104', '9']], []), T0 + 1);121    expect(r.fills.map((f) => [f.clientId, f.size, f.price])).toEqual([[s1.clientId, '1', '105'], [s2.clientId, '0.5', '105']]);122    expect([a.order(s1.clientId)!.status, a.order(s2.clientId)!.status]).toEqual(['filled', 'partially_filled']);123    expect(a.state.positions[0]).toMatchObject({ side: 'short', size: '1.5', entryPrice: '105' });124  });125126  it('ioc limit and post-only', () => {127    const a = account();128    const ioc = a.submit(order({ type: 'limit', tif: 'ioc', price: '100' }), book(T0, [], [['100', '0.25']]), ticker(T0, '100'), T0);129    expect([ioc.order!.status, ioc.order!.filled]).toEqual(['cancelled', '0.25']);130    const crossing = a.submit(order({ type: 'limit', tif: 'alo', price: '100' }), book(T0, [], [['100', '1']]), ticker(T0, '100'), T0);131    expect(crossing.order!.status).toBe('rejected');132    expect(crossing.order!.rejectReason).toMatch(/post-only/);133    const passive = a.submit(order({ type: 'limit', tif: 'alo', price: '99' }), book(T0, [], [['100', '1']]), ticker(T0, '100'), T0);134    expect([passive.order!.status, passive.fills.length]).toEqual(['open', 0]);135  });136});137138describe('paper: rejections, cancellation, idempotency', () => {139  it('rejects malformed or impossible orders and records why', () => {140    const a = account();141    const b = book(T0, [['99', '5']], [['100', '5']]), t = ticker(T0, '100');142    const reason = (o: Partial<OrderRequest>, bk = b, tk = t) => { const r = a.submit(order(o), bk, tk, T0); expect(r.order!.status).toBe('rejected'); expect(r.fills).toEqual([]); return r.order!.rejectReason!; };143    expect(reason({ price: null })).toMatch(/price is required/);144    expect(reason({ size: '0' })).toMatch(/size/);145    expect(reason({ type: 'stop_market', triggerPrice: null })).toMatch(/trigger/);146    expect(reason({ triggerPrice: '90' })).toMatch(/no trigger/);147    expect(reason({ reduceOnly: true, side: 'sell', price: '99' })).toMatch(/no position to reduce/);148    expect(reason({}, book(T0, [], [], 'hyperliquid:ETH'))).toMatch(/another instrument/);149    expect(reason({}, b, ticker(T0, '100', 'hyperliquid:ETH'))).toMatch(/another instrument/);150    expect(reason({ tif: 'alo' })).toMatch(/post-only/);151    expect(a.state.positions).toEqual([]);152    expect(a.state.collateral).toBe('10000');153    expect(a.state.orders.every((o) => o.status === 'rejected')).toBe(true);154    // an order that is not even well formed is not recorded: it throws155    expect(() => a.submit({ ...order(), size: '1e3' }, b, t, T0)).toThrow();156    expect(() => a.submit({ ...order(), side: 'long' as 'buy' }, b, t, T0)).toThrow();157  });158159  it('reduce-only: larger than the position is rejected; never increases', () => {160    const a = account();161    a.submit(order(), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);162    const big = a.submit(order({ side: 'sell', size: '1.5', price: '99', reduceOnly: true }), book(T0, [['100', '5']], []), ticker(T0, '100'), T0);163    expect(big.order!.status).toBe('rejected');164    expect(big.order!.rejectReason).toMatch(/larger than the position/);165    const same = a.submit(order({ side: 'buy', reduceOnly: true }), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);166    expect(same.order!.status).toBe('rejected');167    const part = a.submit(order({ side: 'sell', size: '0.4', price: '99', reduceOnly: true }), book(T0, [['100', '5']], []), ticker(T0, '100'), T0);168    expect(part.order!.status).toBe('filled');169    expect(a.state.positions[0]!.size).toBe('0.6');170  });171172  it('a resting reduce-only order is clipped to the position and cancelled once it is gone', () => {173    const a = account();174    a.submit(order(), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);175    const tp = order({ side: 'sell', type: 'limit', tif: 'gtc', price: '110', size: '1', reduceOnly: true });176    expect(a.submit(tp, book(T0, [['100', '5']], []), ticker(T0, '100'), T0).order!.status).toBe('open');177    // the position shrinks to 0.3 by another order178    a.submit(order({ side: 'sell', size: '0.7', price: '99' }), book(T0, [['100', '5']], []), ticker(T0, '100'), T0);179    const r = a.onBook(book(T0 + 1, [['111', '5']], []), T0 + 1);180    expect(r.fills.map((f) => [f.size, f.price, f.closedPnl])).toEqual([['0.3', '110', '3']]);181    expect(a.state.positions).toEqual([]);182    expect(a.order(tp.clientId)!.status).toBe('cancelled');183    expect(a.order(tp.clientId)!.filled).toBe('0.3');184  });185186  it('cancellation: a cancelled order never fills; cancelling twice or cancelling the unknown is harmless', () => {187    const a = account();188    const req = order({ type: 'limit', tif: 'gtc', price: '90' });189    expect(a.submit(req, book(T0, [], [['100', '5']]), ticker(T0, '100'), T0).order!.status).toBe('open');190    const c = a.cancel(req.clientId, T0 + 1);191    expect([c.order!.status, c.order!.updatedAt, c.order!.rejectReason]).toEqual(['cancelled', T0 + 1, 'cancelled by the user']);192    expect(a.openOrders()).toEqual([]);193    expect(a.onBook(book(T0 + 2, [], [['80', '5']]), T0 + 2).fills).toEqual([]);194    expect(a.state.positions).toEqual([]);195    const again = a.cancel(req.clientId, T0 + 3);196    expect([again.order!.status, again.order!.updatedAt, again.orders.length]).toEqual(['cancelled', T0 + 1, 0]);197    expect(a.cancel('nope', T0 + 3).order).toBeNull();198    // a filled order cannot be cancelled199    const done = order();200    a.submit(done, book(T0 + 4, [], [['100', '5']]), ticker(T0 + 4, '100'), T0 + 4);201    expect(a.cancel(done.clientId, T0 + 5).order!.status).toBe('filled');202  });203204  it('the same fill delivered twice is applied once', () => {205    const a = account();206    const fill: Fill = { instrument: ID, orderId: null, clientId: null, side: 'buy', price: '100', size: '2', fee: '0.2', feeAsset: 'USDC', closedPnl: null, ts: T0, id: 'venue-fill-1', liquidation: false };207    expect(a.applyFill(fill)).toBe(true);208    const snapshot = a.serialize();209    expect(a.applyFill(fill)).toBe(false);210    expect(a.applyFill({ ...fill })).toBe(false);211    expect(a.serialize()).toBe(snapshot);212    expect(a.state.positions[0]).toMatchObject({ side: 'long', size: '2', entryPrice: '100' });213    expect([a.state.collateral, a.state.fills.length]).toEqual(['9999.8', 1]);214    // still deduplicated after a restart215    const b = PaperAccount.restore(snapshot);216    expect(b.applyFill(fill)).toBe(false);217    expect(b.applyFill({ ...fill, id: 'venue-fill-2', side: 'sell', price: '110' })).toBe(true);218    expect(b.state.fills[1]!.closedPnl).toBe('20');219    expect(b.state.collateral).toBe('10019.6');                       // 9 999.8 + 20 − 0.2220  });221222  it('the same clientId submitted twice gives the same order and no second execution', () => {223    const a = account();224    const req = order();225    const b = book(T0, [], [['100', '5']]);226    const first = a.submit(req, b, ticker(T0, '100'), T0);227    const snapshot = a.serialize();228    const second = a.submit(req, book(T0 + 1, [], [['100', '5']]), ticker(T0 + 1, '100'), T0 + 1);229    expect(second.order).toEqual(first.order);230    expect(second.fills).toEqual([]);231    expect(a.serialize()).toBe(snapshot);232  });233234  it('the same book delivered twice does not fill a resting order twice', () => {235    const a = account();236    const req = order({ type: 'limit', tif: 'gtc', price: '100', size: '2' });237    a.submit(req, book(T0, [], [['101', '5']]), ticker(T0, '101'), T0);238    const crossing = book(T0 + 1, [], [['100', '0.5']]);239    expect(a.onBook(crossing, T0 + 1).fills.length).toBe(1);240    expect(a.onBook(crossing, T0 + 2).fills).toEqual([]);241    expect(a.onBook(book(T0, [], [['100', '0.5']]), T0 + 3).fills).toEqual([]);     // older book242    expect(a.order(req.clientId)!.filled).toBe('0.5');243  });244});245246describe('paper: collateral, liquidation, funding', () => {247  it('insufficient collateral: the order is rejected and nothing changes', () => {248    const a = account('100');249    // 11 @ 100 at 10x needs 110 of margin + 1.1 of fee > 100250    const r = a.submit(order({ size: '11', price: '100' }), book(T0, [], [['100', '50']]), ticker(T0, '100'), T0);251    expect(r.order!.status).toBe('rejected');252    expect(r.order!.rejectReason).toMatch(/insufficient collateral: needs 111.1, free 100/);253    expect([a.state.collateral, a.state.positions.length, a.state.fills.length]).toEqual(['100', 0, 0]);254    // 9 @ 100 needs 90 + 0.9: accepted255    const okr = a.submit(order({ size: '9', price: '100' }), book(T0, [], [['100', '50']]), ticker(T0, '100'), T0);256    expect(okr.order!.status).toBe('filled');257    expect(a.state.collateral).toBe('99.1');258    // free is now 99.1 − 90 = 9.1: one more unit (10 + 0.1) does not fit, but reducing always does259    expect(a.submit(order({ size: '1', price: '100' }), book(T0, [], [['100', '50']]), ticker(T0, '100'), T0).order!.status).toBe('rejected');260    expect(a.submit(order({ side: 'sell', size: '4', price: '100' }), book(T0, [['100', '50']], []), ticker(T0, '100'), T0).order!.status).toBe('filled');261    // a resting order is checked again when it fills262    const b = account('100');263    const rest = order({ type: 'limit', tif: 'gtc', size: '9', price: '100' });264    expect(b.submit(rest, book(T0, [], [['101', '50']]), ticker(T0, '101'), T0).order!.status).toBe('open');265    b.submit(order({ size: '5', price: '101' }), book(T0, [], [['101', '50']]), ticker(T0, '101'), T0);266    const later = b.onBook(book(T0 + 1, [], [['100', '50']]), T0 + 1);267    expect(later.fills).toEqual([]);268    expect(b.order(rest.clientId)!.status).toBe('cancelled');269    expect(b.order(rest.clientId)!.rejectReason).toMatch(/insufficient collateral/);270  });271272  it('simulated liquidation when equity falls below the maintenance margin', () => {273    const a = account('100');274    a.submit(order({ size: '9', price: '100' }), book(T0, [], [['100', '50']]), ticker(T0, '100'), T0);    // collateral 99.1275    const resting = order({ side: 'sell', type: 'limit', tif: 'gtc', price: '120', size: '9', reduceOnly: true });276    a.submit(resting, book(T0, [['100', '50']], []), ticker(T0, '100'), T0);277    // the estimate shown for the position: (9 · 100 − 99.1) / (9 · 0.95) = 800.9 / 8.55 = 93.67251461988304…278    expect(a.positions()[0]!.liquidationPrice).toBe('93.672514619883040936');279    // mark 94: equity = 99.1 + 9 · (94 − 100) = 45.1 ; maintenance = 9 · 94 · 0.05 = 42.3 → alive280    const alive = a.onTicker(ticker(T0 + 1, '94'), T0 + 1);281    expect([alive.liquidated, alive.fills.length]).toEqual([false, 0]);282    expect(a.accountState(T0 + 1).equity).toBe('45.1');283    // mark 93: equity = 99.1 − 63 = 36.1 ; maintenance = 9 · 93 · 0.05 = 41.85 → liquidated at the mark284    const dead = a.onTicker(ticker(T0 + 2, '93'), T0 + 2);285    expect(dead.liquidated).toBe(true);286    expect(dead.fills.map((f) => [f.side, f.price, f.size, f.closedPnl, f.liquidation, f.orderId, f.id])).toEqual([['sell', '93', '9', '-63', true, null, 'paper-liq-4']]);287    expect(a.state.positions).toEqual([]);288    expect(a.state.collateral).toBe('36.1');289    expect(a.state.liquidations).toBe(1);290    expect(a.order(resting.clientId)!.status).toBe('cancelled');291    expect(a.order(resting.clientId)!.rejectReason).toMatch(/liquidated/);292    expect(a.openOrders()).toEqual([]);293    // nothing left to liquidate294    expect(a.onTicker(ticker(T0 + 3, '50'), T0 + 3).liquidated).toBe(false);295  });296297  it('a liquidation fee is charged when configured, and a short is liquidated on the way up', () => {298    const a = account('100', { liquidationFeeRate: '0.01' });299    a.submit(order({ side: 'sell', size: '9', price: '100' }), book(T0, [['100', '50']], []), ticker(T0, '100'), T0);   // collateral 99.1300    // mark 107: equity = 99.1 − 63 = 36.1 ; maintenance = 9 · 107 · 0.05 = 48.15 → liquidated ; fee = 9 · 107 · 0.01 = 9.63301    const r = a.onTicker(ticker(T0 + 1, '107'), T0 + 1);302    expect(r.liquidated).toBe(true);303    expect(r.fills.map((f) => [f.side, f.fee, f.closedPnl])).toEqual([['buy', '9.63', '-63']]);304    expect(a.state.collateral).toBe('26.47');                         // 99.1 − 63 − 9.63305  });306307  it('funding: a long pays a positive rate, a short receives it; once per timestamp', () => {308    const a = account();309    a.submit(order({ size: '2' }), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);      // long 2 @ 100, fee 0.2 → 9 999.8310    a.onTicker(ticker(T0 + 1, '110'), T0 + 1);311    // unfavourable: 2 · 110 · 0.0005 = 0.11 paid312    const r = a.applyFunding('0.0005', T0 + 3600_000);313    expect(r.funding).toEqual([{ instrument: ID, amount: '-0.11', rate: '0.0005', size: '2', ts: T0 + 3600_000 }]);314    expect([a.state.collateral, a.state.fundingNet, a.state.positions[0]!.fundingPaid]).toEqual(['9999.69', '-0.11', '0.11']);315    // delivered again for the same timestamp: ignored316    expect(a.applyFunding('0.0005', T0 + 3600_000).funding).toEqual([]);317    expect(a.state.collateral).toBe('9999.69');318    // negative rate: the long receives 2 · 110 · 0.0002 = 0.044319    expect(a.applyFunding('-0.0002', T0 + 7200_000, ID).funding[0]!.amount).toBe('0.044');320    expect([a.state.collateral, a.state.positions[0]!.fundingPaid, a.positions()[0]!.fundingPaid]).toEqual(['9999.734', '0.066', '0.066']);321    // another instrument: nothing322    expect(a.applyFunding('0.01', T0 + 9000_000, 'hyperliquid:ETH').funding).toEqual([]);323    // short side324    const b = account();325    b.submit(order({ side: 'sell', size: '2', price: '99' }), book(T0, [['100', '5']], []), ticker(T0, '100'), T0);326    expect(b.applyFunding('0.0005', T0 + 1).funding[0]!.amount).toBe('0.1');                  // 2 · 100 · 0.0005 received327    expect(b.state.positions[0]!.fundingPaid).toBe('-0.1');328    // flat account: nothing329    expect(account().applyFunding('0.01', T0).funding).toEqual([]);330  });331332  it('unfavourable funding can push an account into liquidation', () => {333    const a = account('100');334    a.submit(order({ size: '9', price: '100' }), book(T0, [], [['100', '50']]), ticker(T0, '100'), T0);    // collateral 99.1, maintenance 45335    // 9 · 100 · 0.06 = 54 of funding: equity 45.1 → still above 45336    expect(a.applyFunding('0.06', T0 + 1).liquidated).toBe(false);337    expect(a.state.collateral).toBe('45.1');338    // 0.9 more: equity 44.2 < 45 → liquidated at the mark 100, no PnL339    const r = a.applyFunding('0.001', T0 + 2);340    expect(r.liquidated).toBe(true);341    expect(r.fills.map((f) => [f.price, f.closedPnl, f.liquidation])).toEqual([['100', '0', true]]);342    expect(a.state.collateral).toBe('44.2');343  });344});345346describe('paper: stop and take-profit', () => {347  const longOne = (): PaperAccount => { const a = account(); a.submit(order(), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0); return a; };348349  it('stop-market: triggers on the mark, executes on the next book within its worst price', () => {350    const a = longOne();351    const stop = order({ side: 'sell', type: 'stop_market', triggerPrice: '95', price: '94', reduceOnly: true });352    expect(a.submit(stop, book(T0, [['100', '5']], []), ticker(T0, '100'), T0).order!.status).toBe('pending');353    expect(a.onTicker(ticker(T0 + 1, '96'), T0 + 1).orders).toEqual([]);354    expect(a.onBook(book(T0 + 1, [['96', '5']], []), T0 + 1).fills).toEqual([]);355    const t = a.onTicker(ticker(T0 + 2, '95'), T0 + 2);356    expect(t.orders.map((o) => [o.clientId, o.status])).toEqual([[stop.clientId, 'triggered']]);357    expect(a.state.positions[0]!.size).toBe('1');                     // not executed yet: it needs a book358    const r = a.onBook(book(T0 + 3, [['94.5', '5']], []), T0 + 3);359    expect(r.fills.map((f) => [f.price, f.size, f.closedPnl, f.fee])).toEqual([['94.5', '1', '-5.5', '0.0945']]);360    expect(a.order(stop.clientId)!.status).toBe('filled');361    expect(a.state.positions).toEqual([]);362  });363364  it('stop-market beyond its worst price is cancelled, not filled at any price', () => {365    const a = longOne();366    const stop = order({ side: 'sell', type: 'stop_market', triggerPrice: '95', price: '94', reduceOnly: true });367    a.submit(stop, book(T0, [['100', '5']], []), ticker(T0, '100'), T0);368    a.onTicker(ticker(T0 + 1, '90'), T0 + 1);369    const r = a.onBook(book(T0 + 2, [['90', '5']], []), T0 + 2);370    expect(r.fills).toEqual([]);371    expect(a.order(stop.clientId)!.status).toBe('cancelled');372    expect(a.state.positions[0]!.size).toBe('1');373  });374375  it('take-profit limit: triggers upward for a sell, then behaves as a limit order', () => {376    const a = longOne();377    const tp = order({ side: 'sell', type: 'take_profit_limit', tif: 'gtc', triggerPrice: '110', price: '110', reduceOnly: true });378    a.submit(tp, book(T0, [['100', '5']], []), ticker(T0, '100'), T0);379    expect(a.onTicker(ticker(T0 + 1, '109.9'), T0 + 1).orders).toEqual([]);380    a.onTicker(ticker(T0 + 2, '110'), T0 + 2);381    expect(a.order(tp.clientId)!.status).toBe('triggered');382    // the book is below the limit: it rests383    a.onBook(book(T0 + 3, [['109', '5']], []), T0 + 3);384    expect(a.order(tp.clientId)!.status).toBe('open');385    const r = a.onBook(book(T0 + 4, [['110.5', '5']], []), T0 + 4);386    expect(r.fills.map((f) => [f.price, f.closedPnl])).toEqual([['110', '10']]);387    expect(a.order(tp.clientId)!.status).toBe('filled');388  });389390  it('buy stop triggers upward, buy take-profit downward; a stop already through its trigger fires at once', () => {391    const a = account();392    const stop = order({ type: 'stop_market', triggerPrice: '105', price: '106' }), tp = order({ type: 'take_profit_market', triggerPrice: '95', price: '96' });393    a.submit(stop, book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);394    a.submit(tp, book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);395    a.onTicker(ticker(T0 + 1, '95'), T0 + 1);396    expect([a.order(stop.clientId)!.status, a.order(tp.clientId)!.status]).toEqual(['pending', 'triggered']);397    a.onTicker(ticker(T0 + 2, '105'), T0 + 2);398    expect(a.order(stop.clientId)!.status).toBe('triggered');399    const now = order({ type: 'stop_market', triggerPrice: '100', price: '106' });400    expect(a.submit(now, book(T0 + 3, [], [['105', '5']]), ticker(T0 + 3, '105'), T0 + 3).order!.status).toBe('triggered');401    // a pending trigger order can be cancelled402    const c = order({ type: 'stop_market', triggerPrice: '200', price: '201' });403    a.submit(c, book(T0 + 3, [], [['105', '5']]), ticker(T0 + 3, '105'), T0 + 3);404    expect(a.cancel(c.clientId, T0 + 4).order!.status).toBe('cancelled');405  });406});407408describe('paper: determinism and restart', () => {409  /** a scenario that touches every feature; `cut` restarts the account (serialise → parse) after that many steps */410  function run(cut: number | null): string {411    let k = 0;412    const id = (): string => `s-${++k}`;413    let a = PaperAccount.create({ venue: 'hyperliquid', account: 'paper-r', collateral: '5000', config: CONFIG });414    const steps: ((x: PaperAccount) => void)[] = [415      (x) => { x.submit(order({ clientId: id(), size: '3', price: '100.5' }), book(T0, [['99.5', '9']], [['100', '2'], ['100.25', '4']]), ticker(T0, '100'), T0); },416      (x) => { x.submit(order({ clientId: id(), type: 'limit', tif: 'gtc', side: 'buy', size: '2', price: '98' }), book(T0 + 1, [['99.5', '9']], [['100', '2']]), ticker(T0 + 1, '100'), T0 + 1); },417      (x) => { x.submit(order({ clientId: id(), type: 'stop_market', side: 'sell', size: '5', triggerPrice: '96', price: '95', reduceOnly: true }), book(T0 + 2, [['99.5', '9']], [['100', '2']]), ticker(T0 + 2, '100'), T0 + 2); },418      (x) => { x.applyFunding('0.000125', T0 + 3); },419      (x) => { x.onTicker(ticker(T0 + 4, '98.37'), T0 + 4); },420      (x) => { x.onBook(book(T0 + 5, [['97.5', '9']], [['97.9', '1.3'], ['98.4', '3']]), T0 + 5); },421      (x) => { x.submit(order({ clientId: id(), type: 'limit', tif: 'gtc', side: 'sell', size: '1', price: '103', reduceOnly: true }), book(T0 + 6, [['97.5', '9']], [['98', '2']]), ticker(T0 + 6, '98'), T0 + 6); },422      (x) => { x.cancel('s-4', T0 + 7); },423      (x) => { x.onBook(book(T0 + 8, [['97', '9']], [['97.2', '5']]), T0 + 8); },424      (x) => { x.applyFunding('-0.0003', T0 + 9); },425      (x) => { x.onTicker(ticker(T0 + 10, '95.9'), T0 + 10); },426      (x) => { x.onBook(book(T0 + 11, [['95.6', '2'], ['95.1', '1'], ['94', '9']], [['96', '5']]), T0 + 11); },427      (x) => { x.submit(order({ clientId: id(), side: 'sell', size: '4', price: '94' }), book(T0 + 12, [['95', '9']], [['96', '5']]), ticker(T0 + 12, '95.5'), T0 + 12); },428      (x) => { x.onTicker(ticker(T0 + 13, '91.123456789'), T0 + 13); },429    ];430    steps.forEach((step, i) => {431      if (cut === i) a = PaperAccount.restore(a.serialize());432      step(a);433    });434    return JSON.stringify({ state: a.state, account: a.accountState(T0 + 99), positions: a.positions(), open: a.openOrders() });435  }436437  it('never reads the clock or a random number', () => {438    vi.spyOn(Date, 'now').mockImplementation(() => { throw new Error('clock used'); });439    vi.spyOn(Math, 'random').mockImplementation(() => { throw new Error('randomness used'); });440    expect(() => run(null)).not.toThrow();441  });442443  it('the same inputs give the same state, byte for byte', () => {444    expect(run(null)).toBe(run(null));445  });446447  it('restart at any point (serialise → parse → continue) gives the same result', () => {448    const reference = run(null);449    const parsed = JSON.parse(reference) as { state: { fills: unknown[]; orders: unknown[]; funding: unknown[]; positions: unknown[] } };450    // the scenario is not trivial: it produced fills, orders, funding and an open position451    expect(parsed.state.fills.length).toBeGreaterThan(4);452    expect(parsed.state.orders.length).toBe(5);453    expect(parsed.state.funding.length).toBe(2);454    expect(parsed.state.positions.length).toBe(1);455    for (let cut = 0; cut < 14; cut++) expect(run(cut), `restart before step ${cut}`).toBe(reference);456  });457458  it('the serialised state validates, and a corrupted one is refused', () => {459    const a = account();460    a.submit(order(), book(T0, [], [['100', '5']]), ticker(T0, '100'), T0);461    const text = a.serialize();462    expect(PaperStateSchema.parse(JSON.parse(text))).toEqual(a.state);463    expect(PaperAccount.restore(text).serialize()).toBe(text);464    expect(PaperAccount.restore(JSON.parse(text)).serialize()).toBe(text);465    expect(() => PaperAccount.restore(text.replace('"collateral":"', '"collateral":"1e'))).toThrow();466    expect(() => PaperAccount.restore('{}')).toThrow();467    expect(() => PaperAccount.restore(JSON.stringify({ ...a.state, version: 2 }))).toThrow();468  });469});470
Files main
Menu

Theme