src/numbers/quant.ts
v0.2.0 · 9.2 KB
// Quantization of sizes and prices to the venue grid.
//
// On Nado lots are arbitrary decimals, not powers of ten (BTC 0.00005, XRP 5, PONS 2, SOL 0.1), and
// ticks are fixed dollar steps (BTC $1, ETH $0.1, XRP $0.0001). Dividing a double by an inexact lot
// produces the `0.29 / 0.01 === 28.999999999999996` class of error, so:
// - the epsilon is applied to the QUOTIENT (`size / lot + eps`), not to the size, and stays far below
// one lot for realistic quotients (< 1e7);
// - the result is rebuilt through BigInt (`lots * lotX18`) and an exact decimal string, never as
// `lots * lot` in floats: the wire string must not carry float dust;
// - one function quantizes everywhere (sizing, validation, sending): two independent implementations
// can disagree by one lot, and the order sent then differs from the size that was computed.
// Inputs that are already exact (decimal strings, x18 bigints) are quantized with integer arithmetic.
import { absX18, decimalToX18, notionalX18, stepDecimals, X18, x18ToDecimalString, x18ToNumber } from './x18.js';
/** Rounding for sizes: `floor` for everything except a FULL reduce-only close, which uses `ceil`. */
export type SizeRounding = 'floor' | 'ceil';
/** Rounding for prices. `round` is the default; `floor` / `ceil` never cross the intended price. */
export type PriceRounding = 'round' | 'floor' | 'ceil';
/** Order side. */
export type Side = 'buy' | 'sell';
/** A size, price or notional given as a JS number, an exact decimal string, or an x18 bigint. */
export type Numeric = number | string | bigint;
/**
* Relative epsilon applied to the quotient `value / step` before flooring or ceiling a double.
* Small enough to stay below one lot for quotients up to ~1e7, large enough to absorb the
* `28.999999999999996` representation error.
*/
export const QUOTIENT_EPSILON = 1e-9;
function assertStep(stepX18: bigint, what: string): void {
if (typeof stepX18 !== 'bigint' || stepX18 <= 0n)
throw new RangeError(`${what} must be a positive x18 bigint, got ${String(stepX18)}`);
}
/**
* Exact quantization of an x18 value to a multiple of `stepX18` using integer arithmetic. Rounding
* applies to the magnitude (`floor` moves toward zero, `ceil` away from zero); the sign is preserved.
*/
export function quantizeX18(valueX18: bigint, stepX18: bigint, rounding: PriceRounding): bigint {
assertStep(stepX18, 'step');
const neg = valueX18 < 0n;
const abs = absX18(valueX18);
let steps: bigint;
switch (rounding) {
case 'floor':
steps = abs / stepX18;
break;
case 'ceil':
steps = (abs + stepX18 - 1n) / stepX18;
break;
case 'round':
steps = (2n * abs + stepX18) / (2n * stepX18);
break;
default:
throw new RangeError(`unknown rounding ${String(rounding)}`);
}
const out = steps * stepX18;
return neg ? -out : out;
}
/** True when `valueX18` is a whole multiple of `stepX18`. */
export function isOnGrid(valueX18: bigint, stepX18: bigint): boolean {
assertStep(stepX18, 'step');
return valueX18 % stepX18 === 0n;
}
/** Number of whole steps in a double `value`, with the quotient epsilon. Never negative. */
function stepsOf(value: number, step: number, rounding: PriceRounding): bigint {
if (!Number.isFinite(value)) throw new RangeError(`cannot quantize ${String(value)}`);
const q = Math.abs(value) / step;
let n: number;
switch (rounding) {
case 'floor':
n = Math.floor(q + QUOTIENT_EPSILON);
break;
case 'ceil':
n = Math.ceil(q - QUOTIENT_EPSILON);
break;
case 'round':
n = Math.round(q);
break;
default:
throw new RangeError(`unknown rounding ${String(rounding)}`);
}
if (!Number.isSafeInteger(n)) throw new RangeError(`${value} / ${step} = ${n} steps does not fit a safe integer`);
return BigInt(Math.max(0, n));
}
function toX18Quantized(value: Numeric, stepX18: bigint, rounding: PriceRounding): bigint {
assertStep(stepX18, 'step');
if (typeof value === 'bigint') return quantizeX18(value, stepX18, rounding);
if (typeof value === 'string') return quantizeX18(decimalToX18(value), stepX18, rounding);
const steps = stepsOf(value, x18ToNumber(stepX18), rounding);
const out = steps * stepX18;
return value < 0 ? -out : out;
}
/**
* Size -> x18 multiple of the lot. Numbers use the quotient epsilon; strings and bigints are exact.
* `floor` (default) for opens and partial reductions; `ceil` only for a full reduce-only close.
* A size that quantizes to `0n` must be SKIPPED by the caller, never sent.
*/
export function quantizeSize(size: Numeric, lotX18: bigint, rounding: SizeRounding = 'floor'): bigint {
return toX18Quantized(size, lotX18, rounding);
}
/**
* Price -> x18 multiple of the tick. `round` (default) is the usual choice for a price already on or
* near the grid; pass {@link priceRoundingForSide} to make sure a quantized limit never crosses the
* intended price (buy floors, sell ceils).
*/
export function quantizePrice(price: Numeric, tickX18: bigint, rounding: PriceRounding = 'round'): bigint {
const out = toX18Quantized(price, tickX18, rounding);
if (out < 0n) throw new RangeError(`price ${String(price)} is negative`);
return out;
}
/** Rounding that keeps a limit price on the safe side of the intended one: buy -> `floor`, sell -> `ceil`. */
export function priceRoundingForSide(side: Side): PriceRounding {
if (side === 'buy') return 'floor';
if (side === 'sell') return 'ceil';
throw new RangeError(`unknown side ${String(side)}`);
}
/**
* True when `|price * size|` reaches `minSizeX18`, the quote-denominated `min_size` of the product
* ($100 on every market as of 2026-09). The venue enforces it on RESTING orders; taker IOCs below
* `min_size` were observed to fill (see `orders.md` §5), so keep two minimums and gate each order by its own.
*/
export function meetsMinSize(priceX18: bigint, sizeX18: bigint, minSizeX18: bigint): boolean {
if (minSizeX18 < 0n) throw new RangeError(`minSizeX18 must not be negative`);
return notionalX18(priceX18, sizeX18) >= minSizeX18;
}
/**
* Smallest size on the lot grid whose notional at `priceX18` reaches `minSizeX18`; `0n` when the price
* is not positive. Used to bump a sub-minimum FULL close above the book minimum (the venue then cuts the
* order at the live position - or answers 2064, see `orders.md` §6).
*/
export function minSizeForNotional(priceX18: bigint, minSizeX18: bigint, lotX18: bigint): bigint {
assertStep(lotX18, 'lot');
if (priceX18 <= 0n) return 0n;
// size >= minSize * 1e18 / price, rounded up to the lot.
const raw = (minSizeX18 * X18 + priceX18 - 1n) / priceX18;
return quantizeX18(raw, lotX18, 'ceil');
}
/** Tick/lot grid of one product plus the quantizers bound to it. */
export interface Quantizer {
readonly tickX18: bigint;
readonly lotX18: bigint;
/** Tick as a double (display, rough maths). */
readonly tick: number;
/** Lot as a double (display, rough maths). */
readonly lot: number;
/** Decimal places of the lot, for logs and `toFixed`; the real grid is the lot. */
readonly sizeDecimals: number;
/** Decimal places of the tick, for logs. */
readonly priceDecimals: number;
/** Size floored to the lot, as a double (0 when it floors to nothing). */
floorSize(size: number): number;
/** Size ceiled to the lot, as a double. ONLY for a full reduce-only close. */
ceilSize(size: number): number;
/** Size -> x18 on the lot grid (see {@link quantizeSize}). */
sizeToX18(size: Numeric, rounding?: SizeRounding): bigint;
/** Price -> x18 on the tick grid (see {@link quantizePrice}). */
priceToX18(price: Numeric, rounding?: PriceRounding): bigint;
/** Price -> exact decimal wire string on the tick grid (`'66120'`, `'1.1281'`). */
priceToDecimal(price: Numeric, rounding?: PriceRounding): string;
/** Size -> exact decimal string on the lot grid. */
sizeToDecimal(size: Numeric, rounding?: SizeRounding): string;
/** True when both values sit on the grid. */
onGrid(input: { priceX18?: bigint; sizeX18?: bigint }): boolean;
}
/**
* Builds the quantizer of one product from its `price_increment_x18` and `size_increment`.
* Keep ONE quantizer per product for planning, preflight and sending.
*/
export function createQuantizer(grid: { tickX18: bigint; lotX18: bigint }): Quantizer {
const { tickX18, lotX18 } = grid;
assertStep(tickX18, 'tickX18');
assertStep(lotX18, 'lotX18');
const tick = x18ToNumber(tickX18);
const lot = x18ToNumber(lotX18);
const asNumber = (v: bigint): number => Number(x18ToDecimalString(v));
return {
tickX18,
lotX18,
tick,
lot,
sizeDecimals: stepDecimals(lotX18),
priceDecimals: stepDecimals(tickX18),
floorSize: (size) => asNumber(quantizeSize(size, lotX18, 'floor')),
ceilSize: (size) => asNumber(quantizeSize(size, lotX18, 'ceil')),
sizeToX18: (size, rounding = 'floor') => quantizeSize(size, lotX18, rounding),
priceToX18: (price, rounding = 'round') => quantizePrice(price, tickX18, rounding),
priceToDecimal: (price, rounding = 'round') => x18ToDecimalString(quantizePrice(price, tickX18, rounding)),
sizeToDecimal: (size, rounding = 'floor') => x18ToDecimalString(quantizeSize(size, lotX18, rounding)),
onGrid: ({ priceX18, sizeX18 }) =>
(priceX18 === undefined || isOnGrid(priceX18, tickX18)) && (sizeX18 === undefined || isOnGrid(sizeX18, lotX18)),
};
}