Repository Public
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