src/risk/margin.test.ts
v0.3.0 · 9.2 KB
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);
});
});