Skip to content
markpaper

src/numbers/quant.ts

v0.2.0 · 9.2 KB

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