Repository Public
1import { describe, expect, it } from 'vitest';2import {3 applyOrderToPosition, D, effectiveLeverage, exposureByAsset, freeCollateralAfterOrder, grossExposure, initialMargin, liquidationPriceAtLeverage,4 liquidationPriceCross, liquidationPriceIsolated, maintenanceMargin, netExposure, notional, signedSize, unrealizedPnl,5 type ExposureInput, type PositionLite, type Side,6} from '../src/index.ts';7import { d, prng, randDec, randInt, s } from './helpers.ts';89const N = 300;1011describe('margin: hand-computed examples', () => {12 it('notional, initial and maintenance margin', () => {13 // 0.5 BTC at 60 000 = 30 000 ; the sign of the size does not matter14 expect(s(notional(d('0.5'), d('60000')))).toBe('30000');15 expect(s(notional(d('-0.5'), d('60000')))).toBe('30000');16 // 30 000 / 20x = 1 50017 expect(s(initialMargin(d('30000'), d('20')))).toBe('1500');18 // 100 / 3 = 33.333… rounded UP at 18 decimals19 expect(s(initialMargin(d('100'), d('3')))).toBe('33.333333333333333334');20 // 30 000 · 0.5 % = 15021 expect(s(maintenanceMargin(d('30000'), d('0.005')))).toBe('150');22 expect(() => initialMargin(d('100'), d('0'))).toThrow(RangeError);23 expect(() => initialMargin(d('-1'), d('2'))).toThrow(RangeError);24 });2526 it('unrealized PnL, long and short', () => {27 // long 0.5 from 60 000 to 61 000: 0.5 · 1 000 = +500 ; the same move costs a short 50028 expect(s(unrealizedPnl('long', d('0.5'), d('60000'), d('61000')))).toBe('500');29 expect(s(unrealizedPnl('short', d('0.5'), d('60000'), d('61000')))).toBe('-500');30 // short 3 from 2 000 to 1 950.5: 3 · 49.5 = +148.531 expect(s(unrealizedPnl('short', d('3'), d('2000'), d('1950.5')))).toBe('148.5');32 expect(s(unrealizedPnl('long', d('3'), d('2000'), d('2000')))).toBe('0');33 });3435 it('exposure and effective leverage of an account', () => {36 // long 0.5 BTC @ 60 000 = 30 000 ; short 10 ETH @ 2 000 = 20 000 ; long 100 SOL @ 150 = 15 00037 const positions: ExposureInput[] = [38 { asset: 'BTC', side: 'long', size: d('0.5'), markPrice: d('60000') },39 { asset: 'ETH', side: 'short', size: d('10'), markPrice: d('2000') },40 { asset: 'SOL', side: 'long', size: d('100'), markPrice: d('150') },41 ];42 expect(s(grossExposure(positions))).toBe('65000'); // 30 000 + 20 000 + 15 00043 expect(s(netExposure(positions))).toBe('25000'); // 30 000 − 20 000 + 15 00044 const by = exposureByAsset([...positions, { asset: 'BTC', side: 'short', size: d('0.1'), markPrice: d('60000') }]);45 expect(s(by.BTC!.long)).toBe('30000');46 expect(s(by.BTC!.short)).toBe('6000'); // 0.1 · 60 00047 expect(s(by.BTC!.net)).toBe('24000');48 expect(s(by.BTC!.gross)).toBe('36000');49 expect(s(by.ETH!.net)).toBe('-20000');50 expect(Object.keys(by).sort()).toEqual(['BTC', 'ETH', 'SOL']);51 // 65 000 / 13 000 = 5x ; 65 000 / 30 000 = 2.1666… rounded up52 expect(s(effectiveLeverage(d('13000'), positions)!)).toBe('5');53 expect(s(effectiveLeverage(d('30000'), positions)!)).toBe('2.166666666666666667');54 expect(effectiveLeverage(d('0'), positions)).toBeNull();55 expect(effectiveLeverage(d('-5'), positions)).toBeNull();56 expect(s(effectiveLeverage(d('100'), [])!)).toBe('0');57 });5859 it('isolated liquidation price, long', () => {60 // long 10 @ 100 with 100 of margin (10x), maintenance 6.25 %:61 // p = (100·10 − 100) / (10 · (1 − 0.0625)) = 900 / 9.375 = 9662 // check at 96: equity = 100 + 10·(96 − 100) = 60 ; maintenance = 0.0625 · 10 · 96 = 6063 const r = liquidationPriceIsolated({ side: 'long', size: d('10'), entryPrice: d('100'), margin: d('100'), maintenanceFraction: d('0.0625') });64 expect(r.source).toBe('estimate');65 expect(r.method).toMatch(/isolated, long/);66 expect(s(r.value!)).toBe('96');67 // maintenance 5 % + closing fee 1.25 % gives the same k = 6.25 %68 const f = liquidationPriceIsolated({ side: 'long', size: d('10'), entryPrice: d('100'), margin: d('100'), maintenanceFraction: d('0.05'), feeRate: d('0.0125') });69 expect(s(f.value!)).toBe('96');70 expect(f.method).toMatch(/closing fee/);71 // rounding: (60 000 − 6 000) / 0.995 = 54 271.356783919597989949748… rounded UP at 18 decimals72 const b = liquidationPriceIsolated({ side: 'long', size: d('1'), entryPrice: d('60000'), margin: d('6000'), maintenanceFraction: d('0.005') });73 expect(s(b.value!)).toBe('54271.35678391959798995');74 // margin ≥ notional: no positive price liquidates the long75 expect(liquidationPriceIsolated({ side: 'long', size: d('10'), entryPrice: d('100'), margin: d('1000'), maintenanceFraction: d('0.05') }).value).toBeNull();76 expect(() => liquidationPriceIsolated({ side: 'long', size: d('1'), entryPrice: d('100'), margin: d('10'), maintenanceFraction: d('1') })).toThrow(RangeError);77 });7879 it('isolated liquidation price, short', () => {80 // short 10 @ 100 with 275 of margin, maintenance 6.25 %:81 // p = (100·10 + 275) / (10 · 1.0625) = 1 275 / 10.625 = 12082 // check at 120: equity = 275 + 10·(100 − 120) = 75 ; maintenance = 0.0625 · 10 · 120 = 7583 const r = liquidationPriceIsolated({ side: 'short', size: d('10'), entryPrice: d('100'), margin: d('275'), maintenanceFraction: d('0.0625') });84 expect(s(r.value!)).toBe('120');85 expect(r.source).toBe('estimate');86 // rounding: (1 000 + 100) / (10 · 1.05) = 104.761904761904761904761… rounded DOWN at 18 decimals87 expect(s(liquidationPriceIsolated({ side: 'short', size: d('10'), entryPrice: d('100'), margin: d('100'), maintenanceFraction: d('0.05') }).value!)).toBe('104.761904761904761904');88 });8990 it('liquidation price from a leverage', () => {91 // long @ 100 at 10x, k = 6.25 %: margin per unit = 10 ; p = (100 − 10) / 0.9375 = 9692 expect(s(liquidationPriceAtLeverage({ side: 'long', entryPrice: d('100'), leverage: d('10'), maintenanceFraction: d('0.0625') }).value!)).toBe('96');93 // short @ 100 at 4x, k = 0 : margin per unit = 25 ; p = 12594 expect(s(liquidationPriceAtLeverage({ side: 'short', entryPrice: d('100'), leverage: d('4'), maintenanceFraction: d('0') }).value!)).toBe('125');95 // long at 1x: margin = notional, never liquidated96 expect(liquidationPriceAtLeverage({ side: 'long', entryPrice: d('100'), leverage: d('1'), maintenanceFraction: d('0.01') }).value).toBeNull();97 });9899 it('cross liquidation price (single-position approximation)', () => {100 // only position: long 10 @ 100, account collateral 500, k = 6.25 %: p = (1 000 − 500) / 9.375 = 53.333… rounded up101 const alone = liquidationPriceCross({ side: 'long', size: d('10'), entryPrice: d('100'), accountCollateral: d('500'), maintenanceFraction: d('0.0625') });102 expect(s(alone.value!)).toBe('53.333333333333333334');103 expect(alone.source).toBe('estimate');104 expect(alone.method).toMatch(/single-position approximation/);105 // other positions frozen at −100 of PnL and 300 of maintenance: margin = 500 − 100 − 300 = 100 → 96 as in the isolated case106 const withOthers = liquidationPriceCross({ side: 'long', size: d('10'), entryPrice: d('100'), accountCollateral: d('500'), maintenanceFraction: d('0.0625'), otherUnrealizedPnl: d('-100'), otherMaintenanceMargin: d('300') });107 expect(s(withOthers.value!)).toBe('96');108 // short 10 @ 100, collateral 275 → 120109 expect(s(liquidationPriceCross({ side: 'short', size: d('10'), entryPrice: d('100'), accountCollateral: d('275'), maintenanceFraction: d('0.0625') }).value!)).toBe('120');110 });111112 it('effect of an order on a position', () => {113 // open114 const open = applyOrderToPosition(null, { side: 'sell', size: d('2'), price: d('100') });115 expect(open.kind).toBe('open');116 expect([open.position!.side, s(open.position!.size), s(open.position!.entryPrice), s(open.realizedPnl)]).toEqual(['short', '2', '100', '0']);117 // increase: long 2 @ 100 + buy 1 @ 130 → 3 @ (200 + 130) / 3 = 110118 const long2: PositionLite = { side: 'long', size: d('2'), entryPrice: d('100') };119 const inc = applyOrderToPosition(long2, { side: 'buy', size: d('1'), price: d('130') });120 expect([inc.kind, s(inc.position!.size), s(inc.position!.entryPrice), s(inc.realizedPnl), s(inc.openedSize)]).toEqual(['increase', '3', '110', '0', '1']);121 // reduce: long 3 @ 110, sell 1 @ 120 → realised 1 · (120 − 110) = 10 ; 2 left at the same entry122 const red = applyOrderToPosition(inc.position, { side: 'sell', size: d('1'), price: d('120') });123 expect([red.kind, s(red.position!.size), s(red.position!.entryPrice), s(red.realizedPnl), s(red.closedSize)]).toEqual(['reduce', '2', '110', '10', '1']);124 // close: sell the 2 left @ 105 → 2 · (105 − 110) = −10125 const close = applyOrderToPosition(red.position, { side: 'sell', size: d('2'), price: d('105') });126 expect([close.kind, close.position, s(close.realizedPnl)]).toEqual(['close', null, '-10']);127 // flip: long 2 @ 100, sell 5 @ 90 → closes 2 with 2 · (90 − 100) = −20 and opens short 3 @ 90128 const flip = applyOrderToPosition(long2, { side: 'sell', size: d('5'), price: d('90') });129 expect([flip.kind, flip.position!.side, s(flip.position!.size), s(flip.position!.entryPrice), s(flip.realizedPnl), s(flip.closedSize), s(flip.openedSize)]).toEqual(['flip', 'short', '3', '90', '-20', '2', '3']);130 // short side: short 4 @ 50, buy 1 @ 40 → realised 1 · (50 − 40) = 10131 const sred = applyOrderToPosition({ side: 'short', size: d('4'), entryPrice: d('50') }, { side: 'buy', size: d('1'), price: d('40') });132 expect([sred.kind, s(sred.realizedPnl), s(sred.position!.size)]).toEqual(['reduce', '10', '3']);133 // average entry that does not terminate: 1 @ 100 + 2 @ 101 → 302 / 3 = 100.666… half-even at 18 decimals134 const avg = applyOrderToPosition({ side: 'long', size: d('1'), entryPrice: d('100') }, { side: 'buy', size: d('2'), price: d('101') });135 expect(s(avg.position!.entryPrice)).toBe('100.666666666666666667');136 expect(() => applyOrderToPosition(null, { side: 'buy', size: d('0'), price: d('1') })).toThrow(RangeError);137 });138139 it('free collateral after a hypothetical order', () => {140 const base = { equity: d('1000'), marginUsed: d('200'), price: d('100'), leverage: d('5') };141 // buy 1 @ 100 at 5x from flat: 1 000 − 200 − 100/5 = 780142 const a = freeCollateralAfterOrder({ ...base, side: 'buy', size: d('1') });143 expect(s(a.value)).toBe('780');144 expect(a.source).toBe('estimate');145 expect(a.method).toMatch(/no fees/);146 // with a 0.1 % fee: 780 − 1 · 100 · 0.001 = 779.9147 expect(s(freeCollateralAfterOrder({ ...base, side: 'buy', size: d('1'), feeRate: d('0.001') }).value)).toBe('779.9');148 // reducing a long 2 by 1: no new margin → 800149 const long2: PositionLite = { side: 'long', size: d('2'), entryPrice: d('90') };150 expect(s(freeCollateralAfterOrder({ ...base, side: 'sell', size: d('1'), position: long2 }).value)).toBe('800');151 // flipping long 2 into short 1 (sell 3): |after| − |before| = 1 − 2 < 0 → no new margin → 800152 expect(s(freeCollateralAfterOrder({ ...base, side: 'sell', size: d('3'), position: long2 }).value)).toBe('800');153 // flipping long 2 into short 4 (sell 6): added = 4 − 2 = 2 → 2 · 100 / 5 = 40 → 760154 expect(s(freeCollateralAfterOrder({ ...base, side: 'sell', size: d('6'), position: long2 }).value)).toBe('760');155 // increasing: buy 3 on long 2 → 3 · 100 / 5 = 60 → 740156 expect(s(freeCollateralAfterOrder({ ...base, side: 'buy', size: d('3'), position: long2 }).value)).toBe('740');157 });158159 it('signedSize', () => {160 expect(s(signedSize({ side: 'long', size: d('2') }))).toBe('2');161 expect(s(signedSize({ side: 'short', size: d('2') }))).toBe('-2');162 expect(s(signedSize(null))).toBe('0');163 });164});165166describe('margin: properties (deterministic PRNG)', () => {167 const price = (r: () => number) => randDec(r, { intDigits: 5, fracDigits: 4, positive: true });168 const size = (r: () => number) => randDec(r, { intDigits: 3, fracDigits: 4, positive: true });169 const side = (r: () => number): Side => (r() < 0.5 ? 'long' : 'short');170171 it('a long liquidates below its entry and the price rises with leverage; a short is the mirror image', () => {172 const r = prng(11);173 for (let i = 0; i < N; i++) {174 const entry = price(r), lev = randInt(r, 2, 49);175 const mmf = d('0.01'); // below 1/leverage for every leverage ≤ 50176 const at = (side_: Side, l: number) => liquidationPriceAtLeverage({ side: side_, entryPrice: entry, leverage: d(BigInt(l)), maintenanceFraction: mmf }).value!;177 const long = at('long', lev), longMore = at('long', lev + 1);178 expect(D.lt(long, entry)).toBe(true);179 expect(D.isPos(long)).toBe(true);180 expect(D.gt(longMore, long)).toBe(true);181 const short = at('short', lev), shortMore = at('short', lev + 1);182 expect(D.gt(short, entry)).toBe(true);183 expect(D.lt(shortMore, short)).toBe(true);184 }185 });186187 it('at the estimated liquidation price, position equity equals the maintenance requirement (to rounding)', () => {188 const r = prng(12);189 const tol = d('0.000000001');190 for (let i = 0; i < N; i++) {191 const sd = side(r), entry = price(r), sz = size(r), mmf = d('0.02');192 const margin = initialMargin(D.mul(entry, sz), d(BigInt(randInt(r, 2, 20))));193 const p = liquidationPriceIsolated({ side: sd, size: sz, entryPrice: entry, margin, maintenanceFraction: mmf }).value!;194 const equity = D.add(margin, unrealizedPnl(sd, sz, entry, p));195 const maint = maintenanceMargin(notional(sz, p), mmf);196 expect(D.lt(D.abs(D.sub(equity, maint)), tol)).toBe(true);197 // more margin moves the liquidation price away from the entry198 // (for a long, enough margin removes the liquidation price altogether: null)199 const p2 = liquidationPriceIsolated({ side: sd, size: sz, entryPrice: entry, margin: D.add(margin, d('1')), maintenanceFraction: mmf }).value;200 if (p2 === null) expect(sd === 'long' && D.gte(D.add(margin, d('1')), D.mul(entry, sz))).toBe(true);201 else expect(sd === 'long' ? D.lt(p2, p) : D.gt(p2, p)).toBe(true);202 }203 });204205 it('closing a position at its entry price realises zero; PnL is antisymmetric between long and short', () => {206 const r = prng(13);207 for (let i = 0; i < N; i++) {208 const sd = side(r), entry = price(r), sz = size(r), mark = price(r);209 const pos: PositionLite = { side: sd, size: sz, entryPrice: entry };210 const close = applyOrderToPosition(pos, { side: sd === 'long' ? 'sell' : 'buy', size: sz, price: entry });211 expect(close.kind).toBe('close');212 expect(close.position).toBeNull();213 expect(s(close.realizedPnl)).toBe('0');214 expect(s(unrealizedPnl(sd, sz, entry, entry))).toBe('0');215 expect(s(D.add(unrealizedPnl('long', sz, entry, mark), unrealizedPnl('short', sz, entry, mark)))).toBe('0');216 }217 });218219 it('a flip realises exactly the PnL of the closed part and opens the rest at the execution price', () => {220 const r = prng(14);221 for (let i = 0; i < N; i++) {222 const sd = side(r), entry = price(r), sz = size(r), px = price(r), extra = size(r);223 const pos: PositionLite = { side: sd, size: sz, entryPrice: entry };224 const execSide = sd === 'long' ? 'sell' as const : 'buy' as const;225 const flip = applyOrderToPosition(pos, { side: execSide, size: D.add(sz, extra), price: px });226 expect(flip.kind).toBe('flip');227 expect(s(flip.realizedPnl)).toBe(s(unrealizedPnl(sd, sz, entry, px)));228 expect(s(flip.closedSize)).toBe(s(sz));229 expect(flip.position!.side).toBe(sd === 'long' ? 'short' : 'long');230 expect(s(flip.position!.size)).toBe(s(extra));231 expect(s(flip.position!.entryPrice)).toBe(s(px));232 // same as closing, then opening233 const closed = applyOrderToPosition(pos, { side: execSide, size: sz, price: px });234 const reopened = applyOrderToPosition(closed.position, { side: execSide, size: extra, price: px });235 expect(s(closed.realizedPnl)).toBe(s(flip.realizedPnl));236 expect(s(reopened.position!.size)).toBe(s(flip.position!.size));237 }238 });239240 it('reducing in two steps at one price realises the same as one step; an increase keeps the cost basis', () => {241 const r = prng(15);242 const tol = d('0.00000000001');243 for (let i = 0; i < N; i++) {244 const sd = side(r), entry = price(r), a = size(r), b = size(r), px = price(r);245 const pos: PositionLite = { side: sd, size: D.add(a, b), entryPrice: entry };246 const out = sd === 'long' ? 'sell' as const : 'buy' as const, into = sd === 'long' ? 'buy' as const : 'sell' as const;247 const one = applyOrderToPosition(pos, { side: out, size: D.add(a, b), price: px });248 const first = applyOrderToPosition(pos, { side: out, size: a, price: px });249 const second = applyOrderToPosition(first.position, { side: out, size: b, price: px });250 expect(s(D.add(first.realizedPnl, second.realizedPnl))).toBe(s(one.realizedPnl));251 expect(second.position).toBeNull();252 // increase: size · entry stays the total cost (to the rounding of the average), entry lies between the two prices253 const inc = applyOrderToPosition({ side: sd, size: a, entryPrice: entry }, { side: into, size: b, price: px });254 const cost = D.add(D.mul(a, entry), D.mul(b, px));255 expect(D.lt(D.abs(D.sub(D.mul(inc.position!.size, inc.position!.entryPrice), cost)), tol)).toBe(true);256 expect(D.gte(inc.position!.entryPrice, D.min(entry, px)) && D.lte(inc.position!.entryPrice, D.max(entry, px))).toBe(true);257 }258 });259260 it('exposure is additive', () => {261 const r = prng(16);262 const assets = ['BTC', 'ETH', 'SOL'];263 const gen = (): ExposureInput[] => Array.from({ length: randInt(r, 0, 5) }, () => ({ asset: assets[randInt(r, 0, 2)]!, side: side(r), size: size(r), markPrice: price(r) }));264 for (let i = 0; i < N; i++) {265 const a = gen(), b = gen(), all = [...a, ...b];266 expect(s(grossExposure(all))).toBe(s(D.add(grossExposure(a), grossExposure(b))));267 expect(s(netExposure(all))).toBe(s(D.add(netExposure(a), netExposure(b))));268 const by = exposureByAsset(all);269 const sum = (k: 'gross' | 'net') => Object.values(by).reduce((acc, e) => D.add(acc, e[k]), D.zero);270 expect(s(sum('gross'))).toBe(s(grossExposure(all)));271 expect(s(sum('net'))).toBe(s(netExposure(all)));272 expect(D.lte(D.abs(netExposure(all)), grossExposure(all))).toBe(true);273 const ba = exposureByAsset(a), bb = exposureByAsset(b);274 for (const k of assets) expect(s(by[k]?.gross ?? D.zero)).toBe(s(D.add(ba[k]?.gross ?? D.zero, bb[k]?.gross ?? D.zero)));275 }276 });277});278