Repository Public
1import { describe, expect, it } from 'vitest';2import {3 checkOrder, RISK_CODES, RiskPolicySchema,4 type AccountState, type Book, type Instrument, type Order, type OrderRequest, type Position, type RiskCode, type RiskContext, type RiskPolicy, type RiskResult, type Ticker,5} from '../src/index.ts';67const ID = 'hyperliquid:BTC';8const NOW = 1_700_000_000_000;910const instrument: Instrument = {11 venue: 'hyperliquid', symbol: 'BTC', id: ID, base: 'BTC', quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: 'USDC',12 tickSize: '1', lotSize: '0.001', minOrderSize: '0.001', minNotional: '10', maxLeverage: '50', marginModes: ['cross', 'isolated'],13 fundingIntervalSec: 3600, markPriceMethod: 'oracle median', status: 'active',14};15const ticker: Ticker = { instrument: ID, mark: '60000', index: '60000', mid: '60000', funding: '0.00001', openInterest: null, volume24h: null, change24h: null, ts: NOW - 500 };16const book: Book = { instrument: ID, bids: [['59999', '5']], asks: [['60001', '5']], ts: NOW - 500 };17const account: AccountState = { venue: 'hyperliquid', account: '0xabc', equity: '10000', collateral: '10000', freeCollateral: '10000', marginUsed: '0', maintenanceMargin: '0', withdrawable: '10000', ts: NOW - 500, source: 'venue' };1819/** the policy under test: every limit is binding somewhere below */20const policy: RiskPolicy = {21 mode: 'PAPER', allowedInstruments: [ID], halt: null, maxDataAgeMs: 5000, maxDeviationBps: '100', maxMarkIndexBps: '50', maxLeverage: '5',22 maxOrderNotional: '20000', maxGrossExposure: '40000', maxPositionNotional: '30000', minFreeCollateral: '1000', minLiquidationDistanceBps: '500',23 maxDailyLoss: '500', maxTotalLoss: '2000', maxOrdersPerMinute: 10, maxOrdersPerDay: 100,24};25/** a policy with every limit out of the way, to isolate one rule */26const loose: RiskPolicy = {27 ...policy, maxDeviationBps: '5000', maxMarkIndexBps: '5000', maxLeverage: '1000', maxOrderNotional: '1000000000', maxGrossExposure: '1000000000',28 maxPositionNotional: '1000000000', minFreeCollateral: '0', minLiquidationDistanceBps: '0', maxDailyLoss: '1000000000', maxTotalLoss: '1000000000',29 maxOrdersPerMinute: 1000000, maxOrdersPerDay: 1000000,30};3132const order = (o: Partial<OrderRequest> = {}): OrderRequest => ({ instrument: ID, side: 'buy', type: 'limit', size: '0.1', price: '60000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'c-1', ...o });33const ctx = (o: Partial<RiskContext> = {}): RiskContext => ({34 instrument, account, positions: [], openOrders: [], book, ticker, now: NOW,35 usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 }, ...o,36});37const position = (o: Partial<Position> = {}): Position => ({38 instrument: ID, side: 'long', size: '0.5', entryPrice: '60000', markPrice: '60000', liquidationPrice: null, leverage: '5', marginMode: 'cross',39 margin: '6000', unrealizedPnl: '0', realizedPnl: null, fundingPaid: null, source: 'venue', ...o,40});41const eth = (o: Partial<Position> = {}): Position => position({ instrument: 'hyperliquid:ETH', size: '10', entryPrice: '2500', markPrice: '2500', margin: '5000', ...o });4243const ok = (r: RiskResult): void => { expect(r).toEqual({ ok: true }); };44const refused = (r: RiskResult, code: RiskCode): void => {45 expect(r.ok).toBe(false);46 if (!r.ok) { expect(r.code).toBe(code); expect(r.reason.length).toBeGreaterThan(5); }47};4849describe('risk: policy schema', () => {50 it('accepts the policy and rejects malformed ones', () => {51 expect(RiskPolicySchema.parse(policy)).toEqual(policy);52 for (const bad of [{ ...policy, mode: 'LIVE' }, { ...policy, maxLeverage: '0' }, { ...policy, maxLeverage: 5 }, { ...policy, maxDailyLoss: '-1' }, { ...policy, maxOrderNotional: '1e6' },53 { ...policy, halt: undefined }, { ...policy, maxDataAgeMs: 1.5 }, { ...policy, allowedInstruments: 'all' }]) {54 expect(RiskPolicySchema.safeParse(bad).success).toBe(false);55 }56 expect(new Set(RISK_CODES).size).toBe(RISK_CODES.length);57 });5859 it('refuses when the policy, the order or the context cannot be read (fail closed)', () => {60 refused(checkOrder({ ...policy, maxLeverage: 'x' } as RiskPolicy, order(), ctx()), 'bad_policy');61 refused(checkOrder(policy, order({ size: '1e-1' }), ctx()), 'bad_order');62 refused(checkOrder(policy, order({ size: '0' }), ctx()), 'bad_order');63 refused(checkOrder(policy, order({ price: '0' }), ctx()), 'bad_order');64 refused(checkOrder(policy, order({ triggerPrice: '59000' }), ctx()), 'bad_order');65 refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, mark: 'abc' } })), 'bad_input');66 refused(checkOrder(policy, order(), ctx({ account: { ...account, equity: 'NaN' } })), 'bad_input');67 refused(checkOrder(policy, order(), ctx({ now: Number.NaN })), 'bad_input');68 refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, mark: '0' } })), 'bad_mark');69 });70});7172describe('risk: one test per rule (accepted just inside, refused just outside)', () => {73 it('baseline order is accepted', () => { ok(checkOrder(policy, order(), ctx())); });7475 it('1 — mode', () => {76 refused(checkOrder({ ...policy, mode: 'READ_ONLY' }, order(), ctx()), 'mode_read_only');77 for (const mode of ['PAPER', 'MANUAL_LIVE', 'MANDATE_LIVE'] as const) ok(checkOrder({ ...policy, mode }, order(), ctx()));78 });7980 it('1 — circuit breaker', () => {81 refused(checkOrder({ ...policy, halt: 'daily loss breaker' }, order(), ctx()), 'halted');82 const r = checkOrder({ ...policy, halt: 'daily loss breaker' }, order(), ctx());83 expect(!r.ok && r.reason).toMatch(/daily loss breaker/);84 // a halt stops new risk, not the way out: a reduce-only order that reduces still passes, one that does not is refused85 expect(checkOrder({ ...policy, halt: 'venue down' }, order({ side: 'sell', reduceOnly: true }), ctx({ positions: [position()] })).ok).toBe(true);86 expect(checkOrder({ ...policy, halt: 'venue down' }, order({ side: 'sell', reduceOnly: true }), ctx()).ok).toBe(false);87 });8889 it('2 — instrument allowed and active', () => {90 refused(checkOrder({ ...policy, allowedInstruments: ['hyperliquid:ETH'] }, order(), ctx()), 'instrument_not_allowed');91 refused(checkOrder({ ...policy, allowedInstruments: [] }, order(), ctx()), 'instrument_not_allowed');92 refused(checkOrder(policy, order({ instrument: 'hyperliquid:ETH' }), ctx()), 'instrument_mismatch');93 refused(checkOrder(policy, order(), ctx({ instrument: { ...instrument, status: 'halted' } })), 'instrument_halted');94 // reduce_only instrument: opening is refused, reducing is accepted (flagged or not)95 const ro = { ...instrument, status: 'reduce_only' as const };96 refused(checkOrder(policy, order(), ctx({ instrument: ro })), 'instrument_reduce_only');97 ok(checkOrder(policy, order({ side: 'sell', size: '0.2', reduceOnly: true }), ctx({ instrument: ro, positions: [position()] })));98 ok(checkOrder(policy, order({ side: 'sell', size: '0.2' }), ctx({ instrument: ro, positions: [position({ size: '0.3' })] })));99 refused(checkOrder(policy, order({ side: 'buy', size: '0.1' }), ctx({ instrument: ro, positions: [position({ size: '0.3' })] })), 'instrument_reduce_only');100 });101102 it('3 — data age (stale price)', () => {103 ok(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW - 5000 } })));104 refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW - 5001 } })), 'stale_ticker');105 ok(checkOrder(policy, order(), ctx({ book: { ...book, ts: NOW - 5000 } })));106 refused(checkOrder(policy, order(), ctx({ book: { ...book, ts: NOW - 5001 } })), 'stale_book');107 // data from the future beyond the tolerance is not trusted either108 refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, ts: NOW + 5001 } })), 'stale_ticker');109 refused(checkOrder(policy, order(), ctx({ ticker: { ...ticker, instrument: 'hyperliquid:ETH' } })), 'data_mismatch');110 refused(checkOrder(policy, order(), ctx({ book: { ...book, instrument: 'hyperliquid:ETH' } })), 'data_mismatch');111 });112113 it('4 — price within maxDeviationBps of the mark (excessive slippage bound)', () => {114 // 100 bps of 60 000 = 600115 ok(checkOrder(policy, order({ price: '60600' }), ctx()));116 refused(checkOrder(policy, order({ price: '60601' }), ctx()), 'price_deviation');117 ok(checkOrder(policy, order({ side: 'sell', price: '59400', reduceOnly: true }), ctx({ positions: [position()] })));118 refused(checkOrder(policy, order({ side: 'sell', price: '59399', reduceOnly: true }), ctx({ positions: [position()] })), 'price_deviation');119 // a market order must carry its worst price, and that bound is checked the same way120 refused(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: null }), ctx()), 'price_required');121 ok(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: '60300' }), ctx()));122 refused(checkOrder(policy, order({ type: 'market', tif: 'ioc', price: '63000' }), ctx()), 'price_deviation');123 });124125 it('4 — trigger orders: the price is compared with the trigger', () => {126 const pos = [position()];127 const stop = (o: Partial<OrderRequest>) => order({ side: 'sell', type: 'stop_limit', reduceOnly: true, size: '0.5', triggerPrice: '57000', price: '56900', ...o });128 ok(checkOrder(policy, stop({}), ctx({ positions: pos })));129 // 100 bps of 57 000 = 570130 ok(checkOrder(policy, stop({ price: '56430' }), ctx({ positions: pos })));131 refused(checkOrder(policy, stop({ price: '56429' }), ctx({ positions: pos })), 'price_deviation');132 refused(checkOrder(policy, stop({ triggerPrice: null }), ctx({ positions: pos })), 'trigger_required');133 refused(checkOrder(policy, stop({ type: 'take_profit_market', triggerPrice: null }), ctx({ positions: pos })), 'trigger_required');134 });135136 it('4 — mark within maxMarkIndexBps of the index', () => {137 // 50 bps of 60 000 = 300138 ok(checkOrder(policy, order({ price: '60300' }), ctx({ ticker: { ...ticker, mark: '60300' } })));139 refused(checkOrder(policy, order({ price: '60301' }), ctx({ ticker: { ...ticker, mark: '60301' } })), 'mark_index_divergence');140 ok(checkOrder(policy, order({ price: '59700' }), ctx({ ticker: { ...ticker, mark: '59700' } })));141 refused(checkOrder(policy, order({ price: '59699' }), ctx({ ticker: { ...ticker, mark: '59699' } })), 'mark_index_divergence');142 // no index published: the check is skipped143 ok(checkOrder(policy, order({ price: '60301' }), ctx({ ticker: { ...ticker, mark: '60301', index: null } })));144 });145146 it('5 — tick, lot, minimum size, minimum notional', () => {147 const half = { ...instrument, tickSize: '0.5' };148 ok(checkOrder(policy, order({ price: '60000.5' }), ctx({ instrument: half })));149 refused(checkOrder(policy, order({ price: '60000.3' }), ctx({ instrument: half })), 'price_not_on_tick');150 refused(checkOrder(policy, order({ price: '60000.5' }), ctx()), 'price_not_on_tick');151 refused(checkOrder(policy, order({ side: 'sell', type: 'stop_market', triggerPrice: '59000.5', price: '58900', reduceOnly: true }), ctx({ positions: [position()] })), 'price_not_on_tick');152 ok(checkOrder(policy, order({ size: '0.101' }), ctx()));153 refused(checkOrder(policy, order({ size: '0.1005' }), ctx()), 'size_not_on_lot');154 const fine = { ...instrument, lotSize: '0.0001' };155 ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: fine })));156 refused(checkOrder(policy, order({ size: '0.0009' }), ctx({ instrument: fine })), 'below_min_size');157 // 0.001 · 60 000 = 60158 ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minNotional: '60' } })));159 refused(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minNotional: '60.01' } })), 'below_min_notional');160 ok(checkOrder(policy, order({ size: '0.001' }), ctx({ instrument: { ...instrument, minOrderSize: null, minNotional: null } })));161 });162163 it('6 — leverage after the order', () => {164 // equity 10 000, limit 5x → gross ≤ 50 000 ; 0.833 · 60 000 = 49 980 ; 0.834 · 60 000 = 50 040165 const p = { ...loose, maxLeverage: '5' };166 ok(checkOrder(p, order({ size: '0.833' }), ctx({ leverage: '50' })));167 refused(checkOrder(p, order({ size: '0.834' }), ctx({ leverage: '50' })), 'leverage_exceeded');168 // other positions count: 10 ETH · 2 500 = 25 000 → BTC up to 25 000 = 0.416 (24 960) ; 0.417 = 25 020169 ok(checkOrder(p, order({ size: '0.416' }), ctx({ leverage: '50', positions: [eth()] })));170 refused(checkOrder(p, order({ size: '0.417' }), ctx({ leverage: '50', positions: [eth()] })), 'leverage_exceeded');171 // no equity: nothing may be opened172 refused(checkOrder(p, order({ size: '0.001' }), ctx({ account: { ...account, equity: '0' } })), 'leverage_exceeded');173 });174175 it('7 — notional per order', () => {176 // 0.333 · 60 000 = 19 980 ; 0.334 · 60 000 = 20 040 ; limit 20 000177 ok(checkOrder(policy, order({ size: '0.333' }), ctx()));178 refused(checkOrder(policy, order({ size: '0.334' }), ctx()), 'order_notional_exceeded');179 // the order is valued at its price when that is above the mark: 0.333 · 60 100 = 20 013.3180 refused(checkOrder(policy, order({ size: '0.333', price: '60100' }), ctx()), 'order_notional_exceeded');181 });182183 it('8 — resulting gross exposure (limit exceeded)', () => {184 // ETH 25 000 already there ; limit 40 000 → BTC up to 15 000 = 0.25 ; 0.251 = 15 060185 ok(checkOrder(policy, order({ size: '0.25' }), ctx({ positions: [eth()] })));186 refused(checkOrder(policy, order({ size: '0.251' }), ctx({ positions: [eth()] })), 'gross_exposure_exceeded');187 // a short adds to gross exposure just the same188 refused(checkOrder(policy, order({ side: 'sell', size: '0.251' }), ctx({ positions: [eth()] })), 'gross_exposure_exceeded');189 });190191 it('9 — resulting position per instrument', () => {192 // long 0.4 (24 000) ; limit 30 000 → +0.1 = 30 000 ; +0.101 = 30 060193 const pos = [position({ size: '0.4' })];194 ok(checkOrder(policy, order({ size: '0.1' }), ctx({ positions: pos })));195 refused(checkOrder(policy, order({ size: '0.101' }), ctx({ positions: pos })), 'position_notional_exceeded');196 // a flip is judged on the position it leaves: long 0.4, sell 0.9 → short 0.5 = 30 000 ; sell 0.901 → 30 060197 const p = { ...policy, maxOrderNotional: '100000' };198 ok(checkOrder(p, order({ side: 'sell', size: '0.9' }), ctx({ positions: pos })));199 refused(checkOrder(p, order({ side: 'sell', size: '0.901' }), ctx({ positions: pos })), 'position_notional_exceeded');200 });201202 it('10 — free collateral after initial margin (insufficient collateral)', () => {203 // 0.1 · 60 000 / 5x = 1 200 of initial margin ; minimum free collateral 1 000204 ok(checkOrder(policy, order(), ctx({ account: { ...account, freeCollateral: '2200' } })));205 refused(checkOrder(policy, order(), ctx({ account: { ...account, freeCollateral: '2199.99' } })), 'insufficient_collateral');206 // the leverage setting of the instrument changes the margin: at 2x it is 3 000207 ok(checkOrder(policy, order(), ctx({ leverage: '2', account: { ...account, freeCollateral: '4000' } })));208 refused(checkOrder(policy, order(), ctx({ leverage: '2', account: { ...account, freeCollateral: '3999' } })), 'insufficient_collateral');209 // an order that only reduces needs no margin, even unflagged210 ok(checkOrder(policy, order({ side: 'sell', size: '0.2' }), ctx({ positions: [position({ size: '0.4' })], account: { ...account, freeCollateral: '1000' } })));211 });212213 it('11 — estimated liquidation distance', () => {214 // cross, single position, maintenance 1 %: long 0.5 @ 60 000 (30 000) on equity E liquidates at (30 000 − E) / (0.5 · 0.99)215 // E = 1 785 → 28 215 / 0.495 = 57 000 → distance 3 000 / 60 000 = 500 bps exactly → accepted216 // E = 1 784.99 → 28 215.01 / 0.495 = 57 000.02… → below 500 bps → refused217 const p = { ...loose, minLiquidationDistanceBps: '500' };218 const c = (equity: string) => ctx({ leverage: '50', maintenanceMarginFraction: '0.01', account: { ...account, equity, freeCollateral: equity } });219 ok(checkOrder(p, order({ size: '0.5' }), c('1785')));220 refused(checkOrder(p, order({ size: '0.5' }), c('1784.99')), 'liquidation_too_close');221 // short: (30 000 + E) / (0.5 · 1.01) ; E = 1 815 → 31 815 / 0.505 = 63 000 → 500 bps exactly222 ok(checkOrder(p, order({ side: 'sell', size: '0.5' }), c('1815')));223 refused(checkOrder(p, order({ side: 'sell', size: '0.5' }), c('1814.99')), 'liquidation_too_close');224 // default maintenance fraction = 1 / (2 · maxLeverage) = 1 % for a 50x instrument: same boundary225 ok(checkOrder(p, order({ size: '0.5' }), ctx({ leverage: '50', account: { ...account, equity: '1785', freeCollateral: '1785' } })));226 refused(checkOrder(p, order({ size: '0.5' }), ctx({ leverage: '50', account: { ...account, equity: '1784.99', freeCollateral: '1784.99' } })), 'liquidation_too_close');227 // isolated position: long 0.5 with 3 000 of margin, +0.5 at 20x adds 1 500 → 1.0 @ 60 000 with 4 500:228 // (60 000 − 4 500) / 0.99 = 56 060.6… → 656 bps → accepted at 500, refused at 700229 const iso = [position({ marginMode: 'isolated', margin: '3000', leverage: '20' })];230 ok(checkOrder(p, order({ size: '0.5' }), ctx({ positions: iso, maintenanceMarginFraction: '0.01' })));231 refused(checkOrder({ ...p, minLiquidationDistanceBps: '700' }, order({ size: '0.5' }), ctx({ positions: iso, maintenanceMarginFraction: '0.01' })), 'liquidation_too_close');232 });233234 it('12 — daily loss', () => {235 const u = (realizedToday: string) => ({ realizedToday, realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 });236 ok(checkOrder(policy, order(), ctx({ usage: u('-499.99') })));237 refused(checkOrder(policy, order(), ctx({ usage: u('-500') })), 'daily_loss_exceeded');238 // unrealized PnL counts: −400 realised and −100 unrealised reach the limit239 const losing = [position({ size: '0.1', unrealizedPnl: '-100' })];240 refused(checkOrder(policy, order(), ctx({ usage: u('-400'), positions: losing })), 'daily_loss_exceeded');241 ok(checkOrder(policy, order(), ctx({ usage: u('-399.99'), positions: losing })));242 ok(checkOrder(policy, order(), ctx({ usage: u('-600'), positions: [position({ size: '0.1', unrealizedPnl: '100.01' })] })));243 });244245 it('13 — total loss', () => {246 const u = (realizedTotal: string) => ({ realizedToday: '0', realizedTotal, ordersLastMinute: 0, ordersToday: 0 });247 ok(checkOrder(policy, order(), ctx({ usage: u('-1999.99') })));248 refused(checkOrder(policy, order(), ctx({ usage: u('-2000') })), 'total_loss_exceeded');249 });250251 it('14 — order rate', () => {252 const u = (ordersLastMinute: number, ordersToday: number) => ({ realizedToday: '0', realizedTotal: '0', ordersLastMinute, ordersToday });253 ok(checkOrder(policy, order(), ctx({ usage: u(9, 99) })));254 refused(checkOrder(policy, order(), ctx({ usage: u(10, 50) })), 'order_rate_minute');255 refused(checkOrder(policy, order(), ctx({ usage: u(5, 100) })), 'order_rate_day');256 refused(checkOrder(policy, order(), ctx({ usage: u(1.5, 0) })), 'bad_input');257 });258259 it('15 — duplicate clientId', () => {260 const open: Order = { ...order({ clientId: 'dup', price: '59000' }), venueOrderId: '1', status: 'open', filled: '0', avgFillPrice: null, createdAt: NOW, updatedAt: NOW, rejectReason: null };261 refused(checkOrder(policy, order({ clientId: 'dup' }), ctx({ openOrders: [open] })), 'duplicate_client_id');262 ok(checkOrder(policy, order({ clientId: 'other' }), ctx({ openOrders: [open] })));263 refused(checkOrder(policy, order({ clientId: 'old' }), ctx({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0, clientIds: ['old'] } })), 'duplicate_client_id');264 });265});266267describe('risk: reduce-only', () => {268 /** an account in bad shape: every exposure and loss limit is already exceeded */269 const stressed = (o: Partial<RiskContext> = {}): RiskContext => ctx({270 positions: [position({ size: '2', unrealizedPnl: '-5000' }), eth({ size: '40' })], // BTC 120 000 + ETH 100 000 of exposure271 account: { ...account, equity: '100', freeCollateral: '-4000' },272 usage: { realizedToday: '-900', realizedTotal: '-9000', ordersLastMinute: 0, ordersToday: 0 },273 maintenanceMarginFraction: '0.01',274 ...o,275 });276 const reduce = (o: Partial<OrderRequest> = {}): OrderRequest => order({ side: 'sell', size: '0.5', reduceOnly: true, ...o });277278 it('an order that really reduces stays possible when exposure and loss limits are exceeded', () => {279 ok(checkOrder(policy, reduce(), stressed()));280 ok(checkOrder(policy, reduce({ size: '2' }), stressed())); // full close281 ok(checkOrder(policy, reduce({ type: 'market', tif: 'ioc', price: '59500' }), stressed()));282 // the same order without the flag is judged like any other283 refused(checkOrder(policy, reduce({ reduceOnly: false }), stressed()), 'leverage_exceeded');284 // and so is an opening order285 refused(checkOrder(policy, order({ size: '0.001' }), stressed()), 'leverage_exceeded');286 });287288 it('each exposure and loss limit is skipped for a reducing reduce-only order, enforced otherwise', () => {289 const pos = [position({ size: '0.4' })];290 const sell = (reduceOnly: boolean) => order({ side: 'sell', size: '0.1', reduceOnly });291 const cases: [RiskCode, RiskPolicy, Partial<RiskContext>][] = [292 ['leverage_exceeded', { ...loose, maxLeverage: '1' }, { positions: pos, account: { ...account, equity: '100' } }],293 ['order_notional_exceeded', { ...loose, maxOrderNotional: '100' }, { positions: pos }],294 ['gross_exposure_exceeded', { ...loose, maxGrossExposure: '100' }, { positions: pos }],295 ['position_notional_exceeded', { ...loose, maxPositionNotional: '100' }, { positions: pos }],296 ['insufficient_collateral', { ...loose, minFreeCollateral: '100' }, { positions: pos, account: { ...account, freeCollateral: '50' } }],297 ['liquidation_too_close', { ...loose, minLiquidationDistanceBps: '9000' }, { positions: pos, account: { ...account, equity: '2000' } }],298 ['daily_loss_exceeded', { ...loose, maxDailyLoss: '10' }, { positions: pos, usage: { realizedToday: '-50', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0 } }],299 ['total_loss_exceeded', { ...loose, maxTotalLoss: '10' }, { positions: pos, usage: { realizedToday: '0', realizedTotal: '-50', ordersLastMinute: 0, ordersToday: 0 } }],300 ];301 for (const [code, p, c] of cases) {302 refused(checkOrder(p, sell(false), ctx(c)), code);303 ok(checkOrder(p, sell(true), ctx(c)));304 }305 });306307 it('a reduce-only order that would increase or flip the position is refused', () => {308 refused(checkOrder(policy, reduce({ size: '2.001' }), stressed()), 'reduce_only_would_flip');309 refused(checkOrder(policy, reduce({ side: 'buy' }), stressed()), 'reduce_only_would_increase');310 refused(checkOrder(policy, reduce(), ctx()), 'reduce_only_would_increase'); // no position at all311 refused(checkOrder(policy, reduce({ side: 'buy' }), ctx({ positions: [position()] })), 'reduce_only_would_increase');312 ok(checkOrder(policy, reduce({ side: 'buy' }), ctx({ positions: [position({ side: 'short' })] })));313 refused(checkOrder(policy, reduce({ side: 'buy', size: '0.501' }), ctx({ positions: [position({ side: 'short' })] })), 'reduce_only_would_flip');314 });315316 it('it must still pass data freshness, price sanity and tick/lot', () => {317 refused(checkOrder(policy, reduce(), stressed({ ticker: { ...ticker, ts: NOW - 60_000 } })), 'stale_ticker');318 refused(checkOrder(policy, reduce(), stressed({ book: { ...book, ts: NOW - 60_000 } })), 'stale_book');319 refused(checkOrder(policy, reduce({ price: '50000' }), stressed()), 'price_deviation');320 refused(checkOrder(policy, reduce(), stressed({ ticker: { ...ticker, mark: '61000' } })), 'price_deviation');321 refused(checkOrder(policy, reduce({ price: '60301' }), stressed({ ticker: { ...ticker, mark: '60301' } })), 'mark_index_divergence');322 refused(checkOrder(policy, reduce({ price: '60000.5' }), stressed()), 'price_not_on_tick');323 refused(checkOrder(policy, reduce({ size: '0.5005' }), stressed()), 'size_not_on_lot');324 refused(checkOrder(policy, reduce({ price: null }), stressed()), 'price_required');325 refused(checkOrder({ ...policy, mode: 'READ_ONLY' }, reduce(), stressed()), 'mode_read_only');326 refused(checkOrder(policy, reduce(), stressed({ instrument: { ...instrument, status: 'halted' } })), 'instrument_halted');327 // the order-rate budget does not block the way out; a duplicate client id still does328 expect(checkOrder(policy, reduce(), stressed({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 10, ordersToday: 10 } })).ok).toBe(true);329 refused(checkOrder(policy, reduce(), stressed({ usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: 0, ordersToday: 0, clientIds: ['c-1'] } })), 'duplicate_client_id');330 });331332 it('dust below the minimums can be closed in full, not in part', () => {333 const fine = { ...instrument, lotSize: '0.0001' };334 const dust = [position({ size: '0.0005' })];335 ok(checkOrder(policy, reduce({ size: '0.0005' }), ctx({ instrument: fine, positions: dust })));336 refused(checkOrder(policy, reduce({ size: '0.0002' }), ctx({ instrument: fine, positions: dust })), 'below_min_size');337 refused(checkOrder(policy, order({ size: '0.0005' }), ctx({ instrument: fine })), 'below_min_size');338 });339});340341describe('risk: order of the checks', () => {342 it('reports the first failing rule in the documented order', () => {343 const everythingWrong: RiskContext = ctx({344 instrument: { ...instrument, status: 'halted' },345 ticker: { ...ticker, ts: NOW - 60_000, mark: '70000' }, book: { ...book, ts: NOW - 60_000 },346 account: { ...account, equity: '1', freeCollateral: '0' },347 usage: { realizedToday: '-9999', realizedTotal: '-9999', ordersLastMinute: 999, ordersToday: 999, clientIds: ['c-1'] },348 });349 const bad = order({ size: '5.0005', price: '60000.5' });350 const sequence: [RiskCode, RiskPolicy, RiskContext][] = [351 ['mode_read_only', { ...policy, mode: 'READ_ONLY', halt: 'x', allowedInstruments: [] }, everythingWrong],352 ['halted', { ...policy, halt: 'x', allowedInstruments: [] }, everythingWrong],353 ['instrument_not_allowed', { ...policy, allowedInstruments: [] }, everythingWrong],354 ['instrument_halted', policy, everythingWrong],355 ['stale_ticker', policy, { ...everythingWrong, instrument }],356 ['stale_book', policy, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' } }],357 ['price_deviation', policy, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' }, book }],358 ['mark_index_divergence', { ...policy, maxDeviationBps: '5000' }, { ...everythingWrong, instrument, ticker: { ...ticker, mark: '70000' }, book }],359 ['price_not_on_tick', policy, { ...everythingWrong, instrument, ticker, book }],360 ];361 for (const [code, p, c] of sequence) refused(checkOrder(p, bad, c), code);362 const c: RiskContext = { ...everythingWrong, instrument, ticker, book };363 refused(checkOrder(policy, order({ size: '5.0005' }), c), 'size_not_on_lot');364 refused(checkOrder(policy, order({ size: '5' }), c), 'leverage_exceeded');365 const rich: RiskContext = { ...c, account: { ...account, equity: '1000000', freeCollateral: '0' } };366 refused(checkOrder(policy, order({ size: '5' }), rich), 'order_notional_exceeded');367 refused(checkOrder({ ...policy, maxOrderNotional: '1000000' }, order({ size: '5' }), rich), 'gross_exposure_exceeded');368 refused(checkOrder({ ...policy, maxOrderNotional: '1000000', maxGrossExposure: '1000000' }, order({ size: '5' }), rich), 'position_notional_exceeded');369 refused(checkOrder(policy, order(), rich), 'insufficient_collateral');370 const funded: RiskContext = { ...rich, account: { ...account, equity: '6100', freeCollateral: '100000' } };371 // long 0.1 @ 60 000 on 6 100 of equity: margin covers the notional → no liquidation price → next rule372 refused(checkOrder(policy, order(), funded), 'daily_loss_exceeded');373 refused(checkOrder({ ...policy, maxDailyLoss: '100000' }, order(), funded), 'total_loss_exceeded');374 refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000' }, order(), funded), 'order_rate_minute');375 refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000 }, order(), funded), 'order_rate_day');376 refused(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000, maxOrdersPerDay: 1000 }, order(), funded), 'duplicate_client_id');377 ok(checkOrder({ ...policy, maxDailyLoss: '100000', maxTotalLoss: '100000', maxOrdersPerMinute: 1000, maxOrdersPerDay: 1000 }, order({ clientId: 'fresh' }), funded));378 });379380 it('is pure: same inputs, same answer, inputs untouched', () => {381 const c = ctx({ positions: [position()] });382 const before = JSON.stringify([policy, order(), c]);383 const a = checkOrder(policy, order(), c), b = checkOrder(policy, order(), c);384 expect(a).toEqual(b);385 expect(JSON.stringify([policy, order(), c])).toBe(before);386 });387});388