src/format/slippage.test.ts
v0.3.0 · 7.4 KB
import { describe, expect, it } from 'vitest';
import { HlFormatError } from './decimal.js';
import { isValidPrice } from './price.js';
import {
DEFAULT_EXIT_SLIPPAGE_FLOOR,
escalatingSlippage,
exitSlippage,
marketLimitPrice,
postOnlyPrice,
} from './slippage.js';
describe('marketLimitPrice', () => {
it('buy = mid * (1 + slippage), sell = mid * (1 - slippage)', () => {
expect(marketLimitPrice({ mid: 100, side: 'B', slippage: 0.01, szDecimals: 2 })).toBe('101');
expect(marketLimitPrice({ mid: 100, side: 'A', slippage: 0.01, szDecimals: 2 })).toBe('99');
expect(marketLimitPrice({ mid: '100', side: 'buy', slippage: 0.005, szDecimals: 2 })).toBe('100.5');
expect(marketLimitPrice({ mid: '100', side: 'sell', slippage: 0.005, szDecimals: 2 })).toBe('99.5');
});
it('rounds aggressively by default so the crossing never shrinks', () => {
// 83.2051 * 1.005 = 83.6211255 ; 83.2051 * 0.995 = 82.7890745 ; tick 0.001
expect(marketLimitPrice({ mid: '83.2051', side: 'B', slippage: 0.005, szDecimals: 2 })).toBe('83.622');
expect(marketLimitPrice({ mid: '83.2051', side: 'A', slippage: 0.005, szDecimals: 2 })).toBe('82.789');
});
it('passive rounding never exceeds the requested slippage', () => {
const base = { mid: '83.2051', slippage: 0.005, szDecimals: 2, rounding: 'passive' } as const;
expect(marketLimitPrice({ ...base, side: 'B' })).toBe('83.621');
expect(marketLimitPrice({ ...base, side: 'A' })).toBe('82.79');
});
it('computes the product exactly (no 1.2100000000000002)', () => {
expect(Math.ceil(1.1 * 1.1 * 1e4) / 1e4).toBe(1.2101); // naive float ceil overshoots a tick
expect(marketLimitPrice({ mid: 1.1, side: 'B', slippage: 0.1, szDecimals: 0 })).toBe('1.21');
expect(marketLimitPrice({ mid: 0.3, side: 'B', slippage: 0.1, szDecimals: 0 })).toBe('0.33');
});
it('lands on the grid across the power-of-ten jump and for BTC', () => {
const px = marketLimitPrice({ mid: 99.9, side: 'B', slippage: 0.01, szDecimals: 2 }); // 100.899
expect(px).toBe('100.9');
expect(isValidPrice(px, { szDecimals: 2 })).toBe(true);
expect(marketLimitPrice({ mid: 79555.5, side: 'B', slippage: 0.01, szDecimals: 5 })).toBe('80352');
expect(marketLimitPrice({ mid: 79555.5, side: 'A', slippage: 0.01, szDecimals: 5 })).toBe('78759');
});
it('rejects zero/invalid slippage (an IoC at mid does not fill)', () => {
for (const slippage of [0, -0.01, 1, 1.5, NaN]) {
expect(() => marketLimitPrice({ mid: 100, side: 'B', slippage, szDecimals: 2 })).toThrow(HlFormatError);
}
});
it('rejects an unusable mid and bad side / rounding', () => {
for (const mid of [0, -1, 'abc', Infinity, '1e20']) {
expect(() => marketLimitPrice({ mid, side: 'B', slippage: 0.01, szDecimals: 2 })).toThrow(HlFormatError);
}
expect(() => marketLimitPrice({ mid: 1, side: 'X' as 'B', slippage: 0.01, szDecimals: 2 })).toThrow(HlFormatError);
expect(() =>
marketLimitPrice({ mid: 1, side: 'B', slippage: 0.01, szDecimals: 2, rounding: 'x' as 'passive' }),
).toThrow(HlFormatError);
});
});
describe('exitSlippage', () => {
it('floors exit slippage at 10% by default', () => {
expect(DEFAULT_EXIT_SLIPPAGE_FLOOR).toBe(0.1);
expect(exitSlippage(0.01)).toBe(0.1);
expect(exitSlippage(0.15)).toBe(0.15);
expect(exitSlippage(0.01, 0.05)).toBe(0.05);
expect(() => exitSlippage(-0.1)).toThrow(HlFormatError);
expect(() => exitSlippage(0.01, 1)).toThrow(HlFormatError);
});
});
describe('escalatingSlippage', () => {
it('grows by 50% of base per attempt, up to 8 steps, capped at 5%', () => {
expect(escalatingSlippage(0.005, 0)).toBeCloseTo(0.005, 12);
expect(escalatingSlippage(0.005, 2)).toBeCloseTo(0.01, 12);
expect(escalatingSlippage(0.005, 8)).toBeCloseTo(0.025, 12);
expect(escalatingSlippage(0.005, 100)).toBeCloseTo(0.025, 12);
expect(escalatingSlippage(0.02, 8)).toBe(0.05);
expect(escalatingSlippage(0.02, 8, { cap: 0.08 })).toBeCloseTo(0.08, 12);
});
it('validates arguments', () => {
expect(() => escalatingSlippage(0.005, -1)).toThrow(HlFormatError);
expect(() => escalatingSlippage(0.005, 1.5)).toThrow(HlFormatError);
expect(() => escalatingSlippage(2, 1)).toThrow(HlFormatError);
});
});
describe('postOnlyPrice', () => {
it('rounds passively without a book', () => {
expect(postOnlyPrice({ px: 83.2059, side: 'B', szDecimals: 2 })).toBe('83.205');
expect(postOnlyPrice({ px: 83.2051, side: 'A', szDecimals: 2 })).toBe('83.206');
});
it('keeps a non-crossing quote unchanged', () => {
expect(postOnlyPrice({ px: 99, side: 'B', bestBid: 98, bestAsk: 100, szDecimals: 2 })).toBe('99');
expect(postOnlyPrice({ px: 101, side: 'A', bestBid: 100, bestAsk: 102, szDecimals: 2 })).toBe('101');
});
it('clamps a bid one tick below the best ask', () => {
expect(postOnlyPrice({ px: 100.5, side: 'B', bestAsk: 100.2, szDecimals: 2 })).toBe('100.19');
// at a power of ten the tick below is finer
expect(postOnlyPrice({ px: 100, side: 'B', bestAsk: 100, szDecimals: 0 })).toBe('99.999');
// off-grid ask: result is still strictly below it
expect(postOnlyPrice({ px: 200, side: 'B', bestAsk: '100.005', szDecimals: 0 })).toBe('100');
});
it('clamps an ask one tick above the best bid', () => {
expect(postOnlyPrice({ px: 99.99, side: 'A', bestBid: 100, szDecimals: 0 })).toBe('100.01');
expect(postOnlyPrice({ px: 99.9, side: 'sell', bestBid: 99.999, szDecimals: 0 })).toBe('100');
});
it('refuses a crossed book', () => {
expect(() => postOnlyPrice({ px: 100, side: 'B', bestBid: 101, bestAsk: 100, szDecimals: 2 })).toThrow(
HlFormatError,
);
expect(() => postOnlyPrice({ px: 100, side: 'B', bestBid: 100, bestAsk: 100, szDecimals: 2 })).toThrow(
HlFormatError,
);
});
});
describe('postOnlyPrice / marketLimitPrice: review edge cases', () => {
it('clamps across a power of ten with the finer tick below it', () => {
// bid target above an ask of exactly 100 (perp, szDecimals 0): one tick below 100 is 99.999
expect(postOnlyPrice({ px: '100.2', side: 'B', bestBid: '99.99', bestAsk: '100', szDecimals: 0 })).toBe('99.999');
// ask target below a bid of 99.999: one tick above is 100
expect(postOnlyPrice({ px: '99.5', side: 'A', bestBid: '99.999', bestAsk: '100.01', szDecimals: 0 })).toBe('100');
});
it('clamps against an off-grid best price without crossing it', () => {
const bid = postOnlyPrice({ px: '83', side: 'B', bestAsk: '82.3885', szDecimals: 2 });
expect(bid).toBe('82.388');
const ask = postOnlyPrice({ px: '82', side: 'A', bestBid: '82.3885', szDecimals: 2 });
expect(ask).toBe('82.389');
});
it('rejects an equal bid/ask book (not quotable)', () => {
expect(() => postOnlyPrice({ px: '1', side: 'B', bestBid: '1.5', bestAsk: '1.5', szDecimals: 0 })).toThrow(HlFormatError);
});
it('spot mids with exponent-sized values stay on the 8-decimal grid', () => {
expect(marketLimitPrice({ mid: '0.00000281', side: 'B', slippage: 0.01, szDecimals: 0, market: 'spot' })).toBe(
'0.00000284',
);
expect(marketLimitPrice({ mid: '0.00000281', side: 'A', slippage: 0.01, szDecimals: 0, market: 'spot' })).toBe(
'0.00000278',
);
// a sell cap that floors to zero is an error, not a "0" price
expect(() => marketLimitPrice({ mid: '0.00000011', side: 'A', slippage: 0.5, szDecimals: 2, market: 'spot' })).toThrow(
HlFormatError,
);
});
});