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