SPB Git

Repository Public

margin.test.ts 18.3 KB 278 lines typescript

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

Theme