Skip to content
markpaper

src/risk/margin.test.ts

v0.3.0 · 9.2 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  assetAllowsCross,
  clampLeverage,
  entryRoe,
  estimateLiquidationPrice,
  grossLeverage,
  leverageMatches,
  leverageUpdateParams,
  liquidationPriceMove,
  liquidationRoe,
  maintenanceMarginFraction,
  marginRatio,
  positionMargin,
  roe,
  roeFromPnl,
  sideOfSzi,
  sideSign,
  stopLiquidationBuffer,
} from './margin.js';

describe('sides', () => {
  it('maps side to sign and signed size to side', () => {
    expect(sideSign('long')).toBe(1);
    expect(sideSign('short')).toBe(-1);
    expect(sideOfSzi('0.5')).toBe('long');
    expect(sideOfSzi('-3')).toBe('short');
    expect(sideOfSzi('0.0')).toBeNull();
    expect(sideOfSzi('garbage')).toBeNull();
  });
});

describe('clampLeverage', () => {
  it('floors, clamps to maxLeverage and never goes below 1', () => {
    expect(clampLeverage(20.9, 50)).toBe(20);
    expect(clampLeverage(75, 50)).toBe(50); // updateLeverage above max is rejected
    expect(clampLeverage(15, 10)).toBe(10);
    expect(clampLeverage(0.5, 10)).toBe(1);
    expect(clampLeverage(-3, 10)).toBe(1);
  });

  it('does not use a missing / zero maxLeverage as a cap', () => {
    expect(clampLeverage(20, 0)).toBe(20);
    expect(clampLeverage(20, Number.NaN)).toBe(20);
    expect(clampLeverage(20, undefined)).toBe(20);
    expect(clampLeverage(20, null)).toBe(20);
  });

  it('throws on non-finite leverage', () => {
    expect(() => clampLeverage(Number.NaN, 10)).toThrow(RangeError);
    expect(() => clampLeverage(Number.POSITIVE_INFINITY, 10)).toThrow(RangeError);
  });
});

describe('leverageUpdateParams / leverageMatches', () => {
  it('derives isCross from onlyIsolated', () => {
    expect(leverageUpdateParams({ maxLeverage: 10, onlyIsolated: true }, 20)).toEqual({ leverage: 10, isCross: false });
    expect(leverageUpdateParams({ maxLeverage: 50 }, 20)).toEqual({ leverage: 20, isCross: true });
    expect(leverageUpdateParams({ maxLeverage: 50, onlyIsolated: false }, 3.7)).toEqual({ leverage: 3, isCross: true });
  });

  it('honours marginMode (onlyIsolated is deprecated upstream)', () => {
    // live meta shapes: xyz:HOOD {onlyIsolated:true, marginMode:'noCross'}, main {onlyIsolated:true, marginMode:'strictIsolated'}
    expect(leverageUpdateParams({ maxLeverage: 10, onlyIsolated: true, marginMode: 'noCross' }, 20)).toEqual({
      leverage: 10,
      isCross: false,
    });
    // the same assets once the deprecated flag is gone
    expect(leverageUpdateParams({ maxLeverage: 10, marginMode: 'noCross' }, 5).isCross).toBe(false);
    expect(leverageUpdateParams({ maxLeverage: 3, marginMode: 'strictIsolated' }, 5)).toEqual({ leverage: 3, isCross: false });
    expect(assetAllowsCross({})).toBe(true);
    expect(assetAllowsCross({ marginMode: '' })).toBe(true);
  });

  it('accepts a raw meta.universe item (extra fields ignored)', () => {
    const item = JSON.parse(
      '{"szDecimals":3,"name":"xyz:SP500","maxLeverage":50,"marginTableId":50,"growthMode":"enabled","deployerFeeScale":"1.0"}',
    );
    expect(leverageUpdateParams(item, 100)).toEqual({ leverage: 50, isCross: true });
  });

  it('skips the round-trip only when floored values match', () => {
    expect(leverageMatches(20, 20)).toBe(true);
    expect(leverageMatches(20.4, 20.9)).toBe(true);
    expect(leverageMatches(0.5, 1)).toBe(true);
    expect(leverageMatches(10, 20)).toBe(false);
    expect(leverageMatches(undefined, 20)).toBe(false); // no position -> leverage unknown
    expect(leverageMatches(null, 20)).toBe(false);
  });
});

describe('ROE', () => {
  it('roeFromPnl = unrealizedPnl / marginUsed, null when margin <= 0', () => {
    expect(roeFromPnl('20', '55')).toBeCloseTo(0.363636, 6);
    expect(roeFromPnl(-40, 80)).toBe(-0.5);
    expect(roeFromPnl('5', '0')).toBeNull();
    expect(roeFromPnl('5', '-1')).toBeNull();
    expect(roeFromPnl('x', '10')).toBeNull();
  });

  it('mark-based roe equals unrealizedPnl / marginUsed on cross (identity)', () => {
    const cases = [
      { szi: 2, entry: 100, mark: 110, lev: 4, side: 'long' as const },
      { szi: -3, entry: 50, mark: 47.5, lev: 20, side: 'short' as const },
      { szi: 0.7, entry: 80, mark: 72, lev: 10, side: 'long' as const },
      { szi: -1.25, entry: 2000, mark: 2150, lev: 25, side: 'short' as const },
    ];
    for (const c of cases) {
      const uPnl = c.szi * (c.mark - c.entry);
      const marginUsed = (Math.abs(c.szi) * c.mark) / c.lev;
      expect(roe(c.entry, c.mark, c.lev, c.side)).toBeCloseTo(uPnl / marginUsed, 12);
    }
  });

  it('4x and +10%: entry form says 40%, mark form 36.4%', () => {
    expect(entryRoe(100, 110, 4, 'long')).toBeCloseTo(0.4, 12);
    expect(roe(100, 110, 4, 'long')).toBeCloseTo(0.3636, 4);
  });

  it('entryRoe matches isolated margin (size × entry / L)', () => {
    const uPnl = -2 * (90 - 100);
    const isolatedMargin = (2 * 100) / 5;
    expect(entryRoe(100, 90, 5, 'short')).toBeCloseTo(uPnl / isolatedMargin, 12);
  });

  it('returns null for invalid prices or leverage', () => {
    expect(roe(0, 100, 10, 'long')).toBeNull();
    expect(roe(100, -1, 10, 'long')).toBeNull();
    expect(roe(100, 100, 0, 'long')).toBeNull();
    expect(entryRoe(Number.NaN, 100, 10, 'long')).toBeNull();
    expect(roe(100, 100, 10, 'short')).toBe(0); // flat price -> zero, not -0
  });
});

describe('liquidation approximation', () => {
  const table: Array<[number, number, number, number]> = [
    // leverage, maxLeverage, liquidation ROE, price move
    [40, 40, -0.5, 0.0125],
    [20, 20, -0.5, 0.025],
    [10, 20, -0.75, 0.075],
    [10, 50, -0.9, 0.09],
    [1, 10, -0.95, 0.95],
  ];

  it('liquidationRoe = leverage / (2 × maxLeverage) − 1', () => {
    for (const [lev, max, liq] of table) expect(liquidationRoe(lev, max)).toBeCloseTo(liq, 12);
  });

  it('liquidationPriceMove = 1/leverage − 1/(2 × maxLeverage)', () => {
    for (const [lev, max, , move] of table) expect(liquidationPriceMove(lev, max)).toBeCloseTo(move, 12);
  });

  it('price move equals −liquidationRoe / leverage', () => {
    for (const [lev, max] of table) {
      expect(liquidationPriceMove(lev, max)).toBeCloseTo(-liquidationRoe(lev, max) / lev, 12);
    }
  });

  it('maintenanceMarginFraction = 1 / (2 × maxLeverage)', () => {
    expect(maintenanceMarginFraction(40)).toBe(0.0125);
    expect(maintenanceMarginFraction(10)).toBe(0.05);
    expect(() => maintenanceMarginFraction(0)).toThrow(RangeError);
  });

  it('falls back to leverage when maxLeverage is missing', () => {
    expect(liquidationRoe(10, 0)).toBe(-0.5);
    expect(liquidationRoe(10, Number.NaN)).toBe(-0.5);
    expect(() => liquidationRoe(0, 10)).toThrow(RangeError);
    expect(() => liquidationPriceMove(Number.NaN, 10)).toThrow(RangeError);
  });

  it('does NOT clamp leverage above maxLeverage (conservative: liquidation is nearer)', () => {
    // A 40x position on a "tiered 40x" table whose notional is in the 20x tier:
    // liquidation is immediate, not at −50%.
    expect(liquidationRoe(40, 20)).toBe(0);
    expect(liquidationPriceMove(40, 20)).toBe(0);
    // 50x on an asset whose max was lowered to 40x: −37.5%, closer than the clamped −50%.
    expect(liquidationRoe(50, 40)).toBeCloseTo(-0.375, 12);
    expect(liquidationPriceMove(50, 40)).toBeCloseTo(0.0075, 12);
    expect(stopLiquidationBuffer(-0.45, 50, 40)).toBeLessThan(0); // stop would never fire first
    expect(estimateLiquidationPrice({ side: 'long', entryPx: 100, leverage: 40, maxLeverage: 20 })).toBe(100);
  });

  it('estimates the liquidation price from entry', () => {
    expect(estimateLiquidationPrice({ side: 'long', entryPx: 100, leverage: 40, maxLeverage: 40 })).toBeCloseTo(98.75, 10);
    expect(estimateLiquidationPrice({ side: 'short', entryPx: 100, leverage: 40, maxLeverage: 40 })).toBeCloseTo(101.25, 10);
    expect(estimateLiquidationPrice({ side: 'long', entryPx: 100, leverage: 1, maxLeverage: 10 })).toBeCloseTo(5, 10);
    expect(estimateLiquidationPrice({ side: 'short', entryPx: 100, leverage: 1, maxLeverage: 10 })).toBeCloseTo(195, 10);
    expect(estimateLiquidationPrice({ side: 'long', entryPx: 0, leverage: 10, maxLeverage: 10 })).toBeNull();
  });

  it('stop buffer: −25% at max leverage leaves 25 points, −50% leaves none', () => {
    expect(stopLiquidationBuffer(-0.25, 20, 20)).toBeCloseTo(0.25, 12);
    expect(stopLiquidationBuffer(-0.5, 40, 40)).toBeCloseTo(0, 12);
    expect(stopLiquidationBuffer(-0.6, 40, 40)).toBeLessThan(0);
  });
});

describe('account-level margin', () => {
  it('positionMargin = |positionValue| / leverage, 0 without leverage', () => {
    expect(positionMargin('-1500', 5)).toBe(300);
    expect(positionMargin(1500, 0)).toBe(0);
    expect(positionMargin(1500, null)).toBe(0);
    expect(positionMargin('bad', 5)).toBe(0);
  });

  it('grossLeverage = Σ|positionValue| / equity, 0 when equity <= 0', () => {
    expect(grossLeverage(['1000', '-500', 'bad'], '500')).toBe(3);
    expect(grossLeverage([1000], 0)).toBe(0);
    expect(grossLeverage([1000], -5)).toBe(0);
  });

  it('marginRatio is fail-closed near liquidation', () => {
    expect(marginRatio('50', '100')).toBe(0.5);
    expect(marginRatio(10, 0)).toBe(Number.POSITIVE_INFINITY);
    expect(marginRatio(0, 0)).toBe(0);
    expect(marginRatio(10, -5)).toBe(Number.POSITIVE_INFINITY);
    expect(marginRatio('x', 100)).toBe(Number.POSITIVE_INFINITY);
  });
});
All files