Skip to content
markpaper

src/format/notional.test.ts

v0.3.0 · 10.3 KB

Download file
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);
  });
});
All files