src/numbers/quant.test.ts
v0.2.0 · 7 KB
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);
});
});