Skip to content
markpaper

src/numbers/quant.test.ts

v0.2.0 · 7 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  createQuantizer,
  isOnGrid,
  meetsMinSize,
  minSizeForNotional,
  priceRoundingForSide,
  quantizePrice,
  quantizeSize,
  quantizeX18,
} from './quant.js';
import { decimalToX18, X18, x18ToDecimalString } from './x18.js';

// Ticks and lots from the public `symbols` (2026-07-24 / 2026-09-11).
const BTC = { tickX18: X18, lotX18: 50000000000000n }; // $1 / 0.00005
const XRP = { tickX18: 100000000000000n, lotX18: 5n * X18 }; // $0.0001 / 5
const ZEC = { tickX18: 10000000000000000n, lotX18: 10000000000000000n }; // $0.01 / 0.01
const ETH = { tickX18: 100000000000000000n, lotX18: 1000000000000000n }; // $0.1 / 0.001

describe('quantizeX18 (exact)', () => {
  it('floors, ceils and rounds on the step grid', () => {
    const lot = XRP.lotX18;
    expect(quantizeX18(decimalToX18('1392.7'), lot, 'floor')).toBe(1390n * X18);
    expect(quantizeX18(decimalToX18('1390.1'), lot, 'ceil')).toBe(1395n * X18);
    expect(quantizeX18(decimalToX18('1392.4'), lot, 'round')).toBe(1390n * X18);
    expect(quantizeX18(decimalToX18('1392.5'), lot, 'round')).toBe(1395n * X18);
    expect(quantizeX18(1390n * X18, lot, 'ceil')).toBe(1390n * X18); // already on grid: no phantom lot
  });

  it('applies rounding to the magnitude and keeps the sign', () => {
    expect(quantizeX18(-decimalToX18('0.00012'), BTC.lotX18, 'floor')).toBe(-decimalToX18('0.0001'));
    expect(quantizeX18(-decimalToX18('0.00012'), BTC.lotX18, 'ceil')).toBe(-decimalToX18('0.00015'));
  });

  it('rejects a non-positive step and unknown rounding', () => {
    expect(() => quantizeX18(1n, 0n, 'floor')).toThrow(RangeError);
    expect(() => quantizeX18(1n, X18, 'nearest' as never)).toThrow(RangeError);
  });
});

describe('quantizeSize / quantizePrice with doubles (knowledge-base table)', () => {
  it('BTC $1 / 0.00005', () => {
    expect(x18ToDecimalString(quantizePrice(66119.7, BTC.tickX18))).toBe('66120');
    expect(x18ToDecimalString(quantizeSize(0.00012, BTC.lotX18))).toBe('0.0001');
    expect(x18ToDecimalString(quantizeSize(0.0001, BTC.lotX18, 'ceil'))).toBe('0.0001');
  });

  it('XRP $0.0001 / 5', () => {
    expect(x18ToDecimalString(quantizeSize(1392.7, XRP.lotX18))).toBe('1390');
    expect(x18ToDecimalString(quantizeSize(1390.1, XRP.lotX18, 'ceil'))).toBe('1395');
    expect(x18ToDecimalString(quantizePrice(1.128064, XRP.tickX18))).toBe('1.1281');
  });

  it('ZEC $0.01 / 0.01: 0.29 stays 0.29, not 0.28', () => {
    expect(x18ToDecimalString(quantizeSize(0.29, ZEC.lotX18))).toBe('0.29');
    expect(x18ToDecimalString(quantizeSize(0.2937, ZEC.lotX18))).toBe('0.29');
    // idempotent
    const once = quantizeSize(0.29, ZEC.lotX18);
    expect(quantizeSize(Number(x18ToDecimalString(once)), ZEC.lotX18)).toBe(once);
  });

  it('ETH $0.1 / 0.001', () => {
    expect(x18ToDecimalString(quantizePrice(1902.37, ETH.tickX18))).toBe('1902.4');
  });

  it('accepts strings and bigints exactly', () => {
    expect(quantizeSize('0.00012', BTC.lotX18)).toBe(decimalToX18('0.0001'));
    expect(quantizeSize(decimalToX18('0.00012'), BTC.lotX18)).toBe(decimalToX18('0.0001'));
    expect(quantizePrice('66119.7', BTC.tickX18)).toBe(66120n * X18);
  });

  it('quantizes tiny sizes to 0n instead of a phantom lot', () => {
    expect(quantizeSize(0.00001, BTC.lotX18)).toBe(0n);
    expect(quantizeSize('0', BTC.lotX18)).toBe(0n);
    expect(quantizeSize(0.00001, BTC.lotX18, 'ceil')).toBe(decimalToX18('0.00005'));
  });

  it('rejects negative prices and non-finite input', () => {
    expect(() => quantizePrice(-1, BTC.tickX18)).toThrow(RangeError);
    expect(() => quantizePrice(Number.NaN, BTC.tickX18)).toThrow(RangeError);
    expect(() => quantizeSize(Number.POSITIVE_INFINITY, BTC.lotX18)).toThrow(RangeError);
  });
});

describe('priceRoundingForSide', () => {
  it('never crosses the intended price: buy floors, sell ceils', () => {
    expect(priceRoundingForSide('buy')).toBe('floor');
    expect(priceRoundingForSide('sell')).toBe('ceil');
    expect(x18ToDecimalString(quantizePrice(66119.7, BTC.tickX18, priceRoundingForSide('buy')))).toBe('66119');
    expect(x18ToDecimalString(quantizePrice(66119.2, BTC.tickX18, priceRoundingForSide('sell')))).toBe('66120');
    expect(() => priceRoundingForSide('long' as never)).toThrow(RangeError);
  });
});

describe('meetsMinSize / minSizeForNotional', () => {
  const min = 100n * X18; // min_size = $100 on every market
  it('compares |price * size| with the quote minimum', () => {
    const px = 66120n * X18;
    expect(meetsMinSize(px, decimalToX18('0.0015'), min)).toBe(false); // $99.18
    expect(meetsMinSize(px, decimalToX18('0.00155'), min)).toBe(true); // $102.49
    expect(meetsMinSize(px, -decimalToX18('0.00155'), min)).toBe(true); // sign of the amount is irrelevant
    expect(() => meetsMinSize(px, 1n, -1n)).toThrow(RangeError);
  });

  it('finds the smallest lot-aligned size that reaches the minimum', () => {
    const px = 66120n * X18;
    const sz = minSizeForNotional(px, min, BTC.lotX18);
    expect(sz).toBe(decimalToX18('0.00155'));
    expect(meetsMinSize(px, sz, min)).toBe(true);
    expect(meetsMinSize(px, sz - BTC.lotX18, min)).toBe(false);
    expect(minSizeForNotional(0n, min, BTC.lotX18)).toBe(0n);
  });
});

describe('isOnGrid', () => {
  it('detects grid membership', () => {
    expect(isOnGrid(1390n * X18, XRP.lotX18)).toBe(true);
    expect(isOnGrid(1392n * X18, XRP.lotX18)).toBe(false);
    expect(isOnGrid(0n, XRP.lotX18)).toBe(true);
  });
});

describe('createQuantizer', () => {
  const q = createQuantizer(BTC);

  it('exposes the grid and decimals', () => {
    expect(q.tick).toBe(1);
    expect(q.lot).toBe(0.00005);
    expect(q.sizeDecimals).toBe(5);
    expect(q.priceDecimals).toBe(0);
  });

  it('floors and ceils sizes as doubles and strings', () => {
    expect(q.floorSize(0.00012)).toBe(0.0001);
    expect(q.ceilSize(0.0001)).toBe(0.0001);
    expect(q.ceilSize(0.00012)).toBe(0.00015);
    expect(q.floorSize(0.00001)).toBe(0);
    expect(q.sizeToDecimal(0.00012)).toBe('0.0001');
    expect(q.sizeToDecimal(0.00012, 'ceil')).toBe('0.00015');
    expect(q.sizeToX18('0.00012', 'ceil')).toBe(decimalToX18('0.00015'));
  });

  it('formats prices exactly for the wire', () => {
    expect(q.priceToDecimal(66119.7)).toBe('66120');
    expect(q.priceToDecimal(66119.7, 'floor')).toBe('66119');
    expect(q.priceToX18(66119.7)).toBe(66120n * X18);
    const xrp = createQuantizer(XRP);
    expect(xrp.priceToDecimal(1.128064)).toBe('1.1281');
    expect(xrp.sizeToDecimal(1392.7)).toBe('1390');
  });

  it('checks grid membership of a pair', () => {
    expect(q.onGrid({ priceX18: 66120n * X18, sizeX18: decimalToX18('0.0001') })).toBe(true);
    expect(q.onGrid({ priceX18: decimalToX18('66120.5') })).toBe(false);
    expect(q.onGrid({ sizeX18: decimalToX18('0.00012') })).toBe(false);
    expect(q.onGrid({})).toBe(true);
  });

  it('rejects an invalid grid', () => {
    expect(() => createQuantizer({ tickX18: 0n, lotX18: X18 })).toThrow(RangeError);
    expect(() => createQuantizer({ tickX18: X18, lotX18: -1n })).toThrow(RangeError);
  });
});
All files