src/format/notional.test.ts
v0.3.0 · 10.3 KB
import { describe, expect, it } from 'vitest';
import { HlFormatError } from './decimal.js';
import {
MIN_ORDER_NOTIONAL_USD,
RECOMMENDED_MIN_QUOTE_NOTIONAL_USD,
bumpToMinNotional,
meetsMinNotional,
notional,
planOrderSize,
quoteSizeFromNotional,
} from './notional.js';
import { marketLimitPrice } from './slippage.js';
describe('constants', () => {
it('exposes the $10 exchange minimum and the $10.50 resting-order floor', () => {
expect(MIN_ORDER_NOTIONAL_USD).toBe(10);
expect(RECOMMENDED_MIN_QUOTE_NOTIONAL_USD).toBe(10.5);
});
});
describe('notional / meetsMinNotional', () => {
it('multiplies exactly', () => {
expect(notional(0.1, 3)).toBe('0.3');
expect(notional('3000', '0.004')).toBe('12');
expect(notional('-2', '5')).toBe('10');
});
it('checks px * sz >= min without float error', () => {
expect(meetsMinNotional(2.5, 4)).toBe(true);
expect(meetsMinNotional('2.5', '3.9999')).toBe(false);
expect(meetsMinNotional('0.7', '14.2857142857142857')).toBe(false); // 9.99999999999999999
expect(meetsMinNotional('0.29', '35')).toBe(true); // 10.15
expect(meetsMinNotional('0.001', '5000', 0)).toBe(true);
});
it('checks after rounding: a $10.5 increment that floors to $7 fails', () => {
// 10.5 / 7 = 1.5 -> floor 1 at szDecimals=0 -> $7
expect(meetsMinNotional(7, 1)).toBe(false);
});
it('rejects invalid minimums', () => {
expect(() => meetsMinNotional(1, 1, -1)).toThrow(HlFormatError);
expect(() => meetsMinNotional(1, 1, NaN)).toThrow(HlFormatError);
});
});
describe('bumpToMinNotional', () => {
it('bumps a full close by as many lots as needed, not one', () => {
expect(bumpToMinNotional(0.001, 3000, 3)).toBe('0.004'); // $12, not 0.002 = $6
expect(bumpToMinNotional(0.001, 2500, 3)).toBe('0.004'); // exactly $10
expect(bumpToMinNotional(0.017, 80, 2)).toBe('0.13'); // ceil 0.02 = $1.60 -> 13 lots
});
it('keeps ceil(size) when it already meets the minimum', () => {
expect(bumpToMinNotional(0.2, 20, 0)).toBe('1');
expect(bumpToMinNotional(5, 1000, 2)).toBe('5');
expect(bumpToMinNotional(0.29, 100, 2)).toBe('0.29');
});
it('supports a custom minimum and rejects bad input', () => {
expect(bumpToMinNotional(0.001, 3000, 3, 0)).toBe('0.001');
expect(() => bumpToMinNotional(0.001, 0, 3)).toThrow(HlFormatError);
expect(() => bumpToMinNotional(0, 3000, 3)).toThrow(HlFormatError);
});
});
describe('planOrderSize: decision table', () => {
it('partial reduceOnly 0.2 @ szDecimals=0 -> skip (never ceil a partial)', () => {
expect(planOrderSize({ intent: 'partialReduce', size: 0.2, px: 100, szDecimals: 0 })).toEqual({
action: 'skip',
reason: 'size_rounds_to_zero',
sz: '0',
notional: '0',
});
});
it('partial reduceOnly 0.001 @ $5000 ($5) -> skip, no bump', () => {
expect(planOrderSize({ intent: 'partialReduce', size: 0.001, px: 5000, szDecimals: 3 })).toEqual({
action: 'skip',
reason: 'order_below_min_after_rounding',
sz: '0.001',
notional: '5',
});
});
it('partial reduceOnly 0.29 @ $35 ($10.15) -> place 0.29 (epsilon)', () => {
expect(planOrderSize({ intent: 'partialReduce', size: 0.29, px: 35, szDecimals: 2 })).toEqual({
action: 'place',
sz: '0.29',
notional: '10.15',
bumped: false,
reduceOnly: true,
});
});
it('full close 0.2 @ szDecimals=0 -> ceil to 1 lot', () => {
expect(planOrderSize({ intent: 'fullClose', size: 0.2, px: 20, szDecimals: 0 })).toEqual({
action: 'place',
sz: '1',
notional: '20',
bumped: false,
reduceOnly: true,
});
const cheap = planOrderSize({ intent: 'fullClose', size: 0.2, px: 5, szDecimals: 0 });
expect(cheap).toMatchObject({ action: 'place', sz: '2', notional: '10', bumped: true });
});
it('full close 0.001 @ $3000 -> 0.004 ($12)', () => {
expect(planOrderSize({ intent: 'fullClose', size: 0.001, px: 3000, szDecimals: 3 })).toEqual({
action: 'place',
sz: '0.004',
notional: '12',
bumped: true,
reduceOnly: true,
});
});
it('open 0.2 @ $1000, szDecimals=0 -> skip (one lot would be a 5x overshoot)', () => {
expect(planOrderSize({ intent: 'open', size: 0.2, px: 1000, szDecimals: 0 })).toMatchObject({
action: 'skip',
reason: 'size_rounds_to_zero',
});
});
it('open 0.0019 @ $5500 -> floor 0.001 = $5.5 -> skip', () => {
expect(planOrderSize({ intent: 'open', size: 0.0019, px: 5500, szDecimals: 3 })).toEqual({
action: 'skip',
reason: 'order_below_min_after_rounding',
sz: '0.001',
notional: '5.5',
});
});
it('open 5 @ $1000, szDecimals=2 -> unchanged', () => {
expect(planOrderSize({ intent: 'open', size: 5, px: 1000, szDecimals: 2 })).toEqual({
action: 'place',
sz: '5',
notional: '5000',
bumped: false,
reduceOnly: false,
});
});
it('increase that floors below the minimum is skipped', () => {
expect(planOrderSize({ intent: 'increase', size: 10.5 / 7, px: 7, szDecimals: 0 })).toMatchObject({
action: 'skip',
reason: 'order_below_min_after_rounding',
sz: '1',
});
});
it('full close keeps exact sizes (no spurious ceil)', () => {
expect(planOrderSize({ intent: 'fullClose', size: 0.29, px: 100, szDecimals: 2 })).toMatchObject({
sz: '0.29',
bumped: false,
});
});
});
describe('planOrderSize: missing price and edge cases', () => {
it('defers a full close and skips an open without a reference price', () => {
expect(planOrderSize({ intent: 'fullClose', size: 1, px: 0, szDecimals: 2 })).toEqual({
action: 'defer',
reason: 'no_reference_price',
});
expect(planOrderSize({ intent: 'fullClose', size: 1, px: NaN, szDecimals: 2 })).toEqual({
action: 'defer',
reason: 'no_reference_price',
});
expect(planOrderSize({ intent: 'open', size: 1, px: 'n/a', szDecimals: 2 })).toEqual({
action: 'skip',
reason: 'no_reference_price',
});
});
it('reports nothing to close for a flat position', () => {
expect(planOrderSize({ intent: 'fullClose', size: 0, px: 100, szDecimals: 2 })).toMatchObject({
action: 'skip',
reason: 'nothing_to_close',
});
});
it('honours a custom minimum', () => {
expect(planOrderSize({ intent: 'open', size: 0.001, px: 5000, szDecimals: 3, minNotionalUsd: 0 })).toMatchObject({
action: 'place',
sz: '0.001',
});
});
it('throws on negative size, unknown intent or bad szDecimals', () => {
expect(() => planOrderSize({ intent: 'open', size: -1, px: 1, szDecimals: 2 })).toThrow(HlFormatError);
expect(() => planOrderSize({ intent: 'flip' as 'open', size: 1, px: 1, szDecimals: 2 })).toThrow(HlFormatError);
expect(() => planOrderSize({ intent: 'open', size: 1, px: 1, szDecimals: 12 })).toThrow(HlFormatError);
});
});
describe('planOrderSize: review regressions', () => {
it('defers a partial reduce without a price (a reduce gated at price 0 is lost forever)', () => {
expect(planOrderSize({ intent: 'partialReduce', size: 1, px: 0, szDecimals: 2 })).toEqual({
action: 'defer',
reason: 'no_reference_price',
});
expect(planOrderSize({ intent: 'partialReduce', size: 1, px: '', szDecimals: 2 })).toEqual({
action: 'defer',
reason: 'no_reference_price',
});
expect(planOrderSize({ intent: 'increase', size: 1, px: -5, szDecimals: 2 })).toEqual({
action: 'skip',
reason: 'no_reference_price',
});
});
it('a full close must be bumped at the IoC limit price, not at mid', () => {
// 0.1 ETH-like lot at mid 100.5, exit with 10% slippage
const mid = '100.5';
const limit = marketLimitPrice({ mid, side: 'A', slippage: 0.1, szDecimals: 2 }); // 90.45
const atMid = planOrderSize({ intent: 'fullClose', size: '0.05', px: mid, szDecimals: 2 });
const atLimit = planOrderSize({ intent: 'fullClose', size: '0.05', px: limit, szDecimals: 2 });
expect(atMid).toMatchObject({ action: 'place', sz: '0.1' });
// 0.1 at the IoC limit price is $9.045: the exchange would reject it
expect(meetsMinNotional(limit, '0.1')).toBe(false);
expect(atLimit).toMatchObject({ action: 'place', sz: '0.12', bumped: true });
expect(meetsMinNotional(limit, '0.12')).toBe(true);
});
it('HIP-3 lot above the minimum: one lot of a $12 xyz market is placeable, sub-lot is not', () => {
expect(planOrderSize({ intent: 'open', size: '1.9', px: '12.0425', szDecimals: 0 })).toMatchObject({
action: 'place',
sz: '1',
notional: '12.0425',
});
expect(planOrderSize({ intent: 'open', size: '0.9', px: '12.0425', szDecimals: 0 })).toMatchObject({
action: 'skip',
reason: 'size_rounds_to_zero',
});
});
it('full close of a huge low-price spot-like size keeps exact lots', () => {
const plan = planOrderSize({ intent: 'fullClose', size: '123456789.0', px: '0.000003', szDecimals: 0 });
expect(plan).toMatchObject({ action: 'place', sz: '123456789', bumped: false });
});
it('float artifacts at the minimum: 0.1 + 0.2 lots priced so the value is exactly $10', () => {
const size = 0.1 + 0.2; // 0.30000000000000004
const plan = planOrderSize({ intent: 'open', size, px: '33.34', szDecimals: 1 });
expect(plan).toMatchObject({ action: 'place', sz: '0.3', notional: '10.002' });
});
});
describe('quoteSizeFromNotional (resting orders sized from USD)', () => {
it('raises an order that floors below $10.50 to the smallest sufficient lot count', () => {
// $0.80 order at a bid of 99.2 -> floor 0 lots; needs ceil(10.5 / (99.2 * 0.01)) = 11 lots
expect(quoteSizeFromNotional(0.8, '99.2', 2)).toBe('0.11');
expect(meetsMinNotional('99.2', '0.11', RECOMMENDED_MIN_QUOTE_NOTIONAL_USD)).toBe(true);
// $10 "exactly" at a bid below mid floors to $9.92 -> bumped
expect(quoteSizeFromNotional(10, '99.2', 2)).toBe('0.11');
});
it('keeps the floored size when it already clears the floor and rejects bad input', () => {
expect(quoteSizeFromNotional(50, '100', 2)).toBe('0.5');
expect(quoteSizeFromNotional(0, '100', 2)).toBe('0');
expect(quoteSizeFromNotional(-5, '100', 2)).toBe('0');
expect(quoteSizeFromNotional(5, '100', 2, 10)).toBe('0.1');
expect(() => quoteSizeFromNotional(5, 0, 2)).toThrow(HlFormatError);
expect(() => quoteSizeFromNotional(5, 1, 9)).toThrow(HlFormatError);
expect(() => quoteSizeFromNotional(5, 1, 2, Number.NaN)).toThrow(HlFormatError);
});
});