Skip to content
markpaper

src/risk/stops.test.ts

v0.3.0 · 6.5 KB

Download file
import { describe, expect, it } from 'vitest';
import { roe, entryRoe } from './margin.js';
import { averageEntryPx, roeToPrice, roeToTriggerPrice, tpslLegPrices } from './stops.js';

describe('roeToPrice', () => {
  it('entry basis: entry × (1 + s); mark basis: entry / (1 − s)', () => {
    expect(roeToPrice({ side: 'long', entryPx: 100, leverage: 10, roe: 0.5, basis: 'entry' })).toBeCloseTo(105, 10);
    expect(roeToPrice({ side: 'long', entryPx: 100, leverage: 10, roe: 0.5, basis: 'mark' })).toBeCloseTo(100 / 0.95, 10);
  });

  it('round-trips with the matching ROE formula', () => {
    const markPx = roeToPrice({ side: 'short', entryPx: 250, leverage: 6, roe: -0.25, basis: 'mark' });
    expect(markPx).not.toBeNull();
    expect(roe(250, markPx as number, 6, 'short')).toBeCloseTo(-0.25, 12);
    const entryPx = roeToPrice({ side: 'long', entryPx: 250, leverage: 6, roe: 0.4, basis: 'entry' });
    expect(entryRoe(250, entryPx as number, 6, 'long')).toBeCloseTo(0.4, 12);
  });

  it('returns null for unreachable mark levels and negative prices', () => {
    expect(roeToPrice({ side: 'long', entryPx: 100, leverage: 1, roe: 1.03, basis: 'mark' })).toBeNull();
    expect(roeToPrice({ side: 'long', entryPx: 100, leverage: 1, roe: -1.2, basis: 'entry' })).toBeNull();
    expect(roeToPrice({ side: 'long', entryPx: 100, leverage: 0.5, roe: 0.1, basis: 'entry' })).toBeNull();
  });
});

describe('roeToTriggerPrice (conservative)', () => {
  // entry 100, synthetic levels: stop −25%, take-profit +50%
  const cases: Array<[string, 'long' | 'short', number, number, number | null]> = [
    ['long 10x SL', 'long', 10, -0.25, 97.5],
    ['long 10x TP', 'long', 10, 0.5, 100 / 0.95],
    ['short 10x SL', 'short', 10, -0.25, 100 / 0.975],
    ['short 10x TP', 'short', 10, 0.5, 95],
    ['long 1x SL', 'long', 1, -0.25, 75],
    ['long 1x TP', 'long', 1, 0.5, 100 / 0.5],
    ['short 1x TP', 'short', 1, 0.5, 50],
    ['long 50x SL', 'long', 50, -0.25, 100 * (1 - 25 / 5000)],
    ['long 10x SL −35%', 'long', 10, -0.35, 96.5],
    ['long 1x TP 110 -> degenerate', 'long', 1, 1.1, null],
    ['long 1x SL 120 -> negative price', 'long', 1, -1.2, null],
  ];

  for (const [name, side, leverage, level, expected] of cases) {
    it(name, () => {
      const px = roeToTriggerPrice({ side, entryPx: 100, leverage, roe: level });
      if (expected === null) expect(px).toBeNull();
      else expect(px).toBeCloseTo(expected, 9);
    });
  }

  it('long 1x TP matches 200 and short 10x SL 102.56', () => {
    expect(roeToTriggerPrice({ side: 'long', entryPx: 100, leverage: 1, roe: 0.5 })).toBeCloseTo(200, 2);
    expect(roeToTriggerPrice({ side: 'short', entryPx: 100, leverage: 10, roe: -0.25 })).toBeCloseTo(102.56, 2);
  });

  it('is always the level farther from entry than either basis', () => {
    for (const side of ['long', 'short'] as const) {
      for (const level of [-0.4, -0.1, 0.1, 0.4]) {
        const input = { side, entryPx: 100, leverage: 5, roe: level };
        const t = roeToTriggerPrice(input) as number;
        const e = roeToPrice({ ...input, basis: 'entry' }) as number;
        const m = roeToPrice({ ...input, basis: 'mark' }) as number;
        expect(Math.abs(t - 100)).toBeGreaterThanOrEqual(Math.abs(e - 100) - 1e-9);
        expect(Math.abs(t - 100)).toBeGreaterThanOrEqual(Math.abs(m - 100) - 1e-9);
      }
    }
  });

  it('rejects invalid entry and leverage below 1', () => {
    expect(roeToTriggerPrice({ side: 'long', entryPx: 0, leverage: 10, roe: -0.1 })).toBeNull();
    expect(roeToTriggerPrice({ side: 'long', entryPx: 100, leverage: 0.5, roe: -0.1 })).toBeNull();
    expect(roeToTriggerPrice({ side: 'long', entryPx: 100, leverage: 10, roe: Number.NaN })).toBeNull();
    expect(roeToTriggerPrice({ side: 'long', entryPx: 100, leverage: 10, roe: 0 })).toBe(100);
  });
});

describe('tpslLegPrices', () => {
  it('long SL: sell exit, limit = trigger × (1 − slip)', () => {
    const leg = tpslLegPrices({ side: 'long', entryPx: 100, leverage: 10, roe: -0.25, closeSlippage: 0.1 });
    expect(leg?.isBuy).toBe(false);
    expect(leg?.triggerPx).toBeCloseTo(97.5, 10);
    expect(leg?.limitPx).toBeCloseTo(87.75, 10);
  });

  it('short legs: buy exit, limit = trigger × (1 + slip)', () => {
    const leg = tpslLegPrices({ side: 'short', entryPx: 100, leverage: 10, roe: 0.5, closeSlippage: 0.1 });
    expect(leg?.isBuy).toBe(true);
    expect(leg?.limitPx).toBeCloseTo(95 * 1.1, 10);
  });

  it('stop leg with zero slippage at entry 200 and 5x: limit equals trigger', () => {
    const leg = tpslLegPrices({ side: 'long', entryPx: 200, leverage: 5, roe: -0.25, closeSlippage: 0 });
    expect(leg?.triggerPx).toBeCloseTo(200 * (1 - 25 / 500), 10);
    expect(leg?.limitPx).toBe(leg?.triggerPx);
  });

  it('returns null for a degenerate leg and validates slippage', () => {
    expect(tpslLegPrices({ side: 'long', entryPx: 100, leverage: 1, roe: 1.03, closeSlippage: 0.1 })).toBeNull();
    expect(() => tpslLegPrices({ side: 'long', entryPx: 100, leverage: 1, roe: 0.1, closeSlippage: 1 })).toThrow(RangeError);
    expect(() => tpslLegPrices({ side: 'long', entryPx: 100, leverage: 1, roe: 0.1, closeSlippage: -0.1 })).toThrow(RangeError);
  });
});

describe('averageEntryPx', () => {
  it('computes the size-weighted average', () => {
    expect(averageEntryPx({ prevSize: 2, prevEntryPx: 100, fillSz: 2, fillPx: 110 })).toBe(105);
    expect(averageEntryPx({ prevSize: -1, prevEntryPx: 100, fillSz: -3, fillPx: 80 })).toBe(85);
  });

  it('falls back to the fill price when the previous entry is unknown', () => {
    expect(averageEntryPx({ prevSize: 2, prevEntryPx: null, fillSz: 1, fillPx: 90 })).toBe(90);
    expect(averageEntryPx({ prevSize: 2, prevEntryPx: 0, fillSz: 1, fillPx: 90 })).toBe(90);
    expect(averageEntryPx({ prevSize: 0, prevEntryPx: 100, fillSz: 1, fillPx: 90 })).toBe(90);
  });

  it('keeps products exact on real szDecimals (0.1 + 0.2 lots)', () => {
    // (0.1 × 80.1 + 0.2 × 80.4) / 0.3 = 80.3; the float numerator is 24.089999999999996
    expect(averageEntryPx({ prevSize: 0.1, prevEntryPx: 80.1, fillSz: 0.2, fillPx: 80.4 })).toBe(80.3);
    expect(averageEntryPx({ prevSize: 3, prevEntryPx: 1.1, fillSz: 0, fillPx: 2 })).toBe(1.1);
    expect(averageEntryPx({ prevSize: 1, prevEntryPx: 1, fillSz: 2, fillPx: 1.15 })).toBe(1.1);
  });

  it('throws on an invalid fill price', () => {
    expect(() => averageEntryPx({ prevSize: 1, prevEntryPx: 100, fillSz: 1, fillPx: 0 })).toThrow(RangeError);
    expect(() => averageEntryPx({ prevSize: 1, prevEntryPx: 100, fillSz: Number.NaN, fillPx: 1 })).toThrow(RangeError);
  });
});
All files