Skip to content
markpaper

src/numbers/quantize.ts

v0.2.1 · 8.3 KB

Download file
// Size and price grids of a Lighter market and the integer wire encoding.
//
// Lighter steps are honest powers of ten: lot = 10^-supported_size_decimals, tick = 10^-supported_price_decimals.
// On the wire both are integers: base_amount = size * 10^sizeDecimals, price = price * 10^priceDecimals
// (knowledge base: markets-and-numbers.md §3). Planner and executor must quantize with ONE function:
// two implementations that disagree by one lot produce sizes that never match the book.

import { LighterNumbersError } from './errors.js';

/** Upper bound for `supported_*_decimals`; anything above is treated as corrupt meta. */
export const MAX_DECIMALS = 12;

/**
 * Relative epsilon used by floor/ceil so that a size already on the grid (e.g. `0.3` stored as
 * `0.29999999999999999`) is not pushed one lot down or up.
 */
const SNAP_EPSILON = 1e-9;

export type SizeRounding = 'floor' | 'ceil';
export type PriceRounding = 'round' | 'floor' | 'ceil';

export function assertDecimals(decimals: number, label = 'decimals'): void {
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {
    throw new LighterNumbersError(`Invalid ${label}: ${String(decimals)} (expected an integer 0..${MAX_DECIMALS})`);
  }
}

function assertFinite(value: number, label: string): void {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    throw new LighterNumbersError(`Invalid ${label}: ${String(value)}`);
  }
}

/**
 * Size lot of a market: `10^-sizeDecimals` computed as `Number((10 ** -n).toFixed(n))`.
 * `10 ** -4` alone is `0.00009999999999999999`, so a size compared with it never matches the
 * exchange's value (knowledge base: markets-and-numbers.md TL;DR 4).
 */
export function sizeLot(sizeDecimals: number): number {
  assertDecimals(sizeDecimals, 'sizeDecimals');
  return Number((10 ** -sizeDecimals).toFixed(sizeDecimals));
}

/** Price tick of a market: `10^-priceDecimals`, same construction as {@link sizeLot}. */
export function priceTick(priceDecimals: number): number {
  assertDecimals(priceDecimals, 'priceDecimals');
  return Number((10 ** -priceDecimals).toFixed(priceDecimals));
}

/**
 * Quantizes a size to the market lot. `floor` for everything except a FULL reduceOnly close,
 * which uses `ceil` (the exchange caps a reduceOnly order at the live position, so oversize is
 * harmless while undersize leaves dust). Negative input clamps to 0.
 */
export function quantizeSize(size: number, sizeDecimals: number, mode: SizeRounding = 'floor'): number {
  assertFinite(size, 'size');
  const lot = sizeLot(sizeDecimals);
  const lots = mode === 'ceil' ? Math.ceil(size / lot - SNAP_EPSILON) : Math.floor(size / lot + SNAP_EPSILON);
  return Number((Math.max(0, lots) * lot).toFixed(sizeDecimals));
}

/** Wire-ready size string on the lot grid (see {@link quantizeSize}). */
export function formatSize(size: number, sizeDecimals: number, mode: SizeRounding = 'floor'): string {
  return quantizeSize(size, sizeDecimals, mode).toFixed(sizeDecimals);
}

/**
 * Price on the tick grid as an EXACT string (`px.toFixed(priceDecimals)` for `round`).
 * Keep the string next to the number: prices come back from the exchange as strings (`"52.37"`),
 * and prices are compared as strings, not as floats. Planned prices computed off this grid must be
 * put on it BEFORE comparing them with the book: a target `41.236` sent as `41.24` and read
 * back as `41.24` never matches its own target otherwise (markets-and-numbers.md §3).
 */
export function quantizePrice(price: number, priceDecimals: number, mode: PriceRounding = 'round'): string {
  assertFinite(price, 'price');
  assertDecimals(priceDecimals, 'priceDecimals');
  if (mode === 'round') return price.toFixed(priceDecimals);
  const tick = priceTick(priceDecimals);
  const ticks = mode === 'ceil' ? Math.ceil(price / tick - SNAP_EPSILON) : Math.floor(price / tick + SNAP_EPSILON);
  return (ticks * tick).toFixed(priceDecimals);
}

/** True when `size` already lies on the lot grid (within {@link SNAP_EPSILON} of a lot multiple). */
export function isOnSizeGrid(size: number, sizeDecimals: number): boolean {
  assertFinite(size, 'size');
  const lot = sizeLot(sizeDecimals);
  const lots = size / lot;
  return Math.abs(lots - Math.round(lots)) < SNAP_EPSILON;
}

/** True when a price string has at most `priceDecimals` fractional digits. */
export function isOnPriceGrid(priceStr: string, priceDecimals: number): boolean {
  assertDecimals(priceDecimals, 'priceDecimals');
  if (!/^\d+(\.\d+)?$/.test(priceStr)) return false;
  const frac = priceStr.split('.')[1] ?? '';
  return frac.length <= priceDecimals;
}

/** Number of fractional digits in a plain decimal string (`"1605.34"` -> 2, `"7"` -> 0). */
export function decimalsOf(str: string): number {
  return str.split('.')[1]?.length ?? 0;
}

/**
 * Exact integer from a plain decimal string scaled by `10^decimals` (`"1605.34"`, 2 -> 160534).
 * String arithmetic, not `Math.round(Number(s) * 10 ** d)`: no float product to round.
 *
 * @throws LighterNumbersError when the string has more fractional digits than `decimals` (not on the grid),
 *   is not a plain non-negative decimal, or the result is not a safe integer.
 */
export function scaledIntegerFromString(str: string, decimals: number, label = 'value'): number {
  assertDecimals(decimals, 'decimals');
  const m = /^(\d+)(?:\.(\d*))?$/.exec(str.trim());
  if (!m)
    throw new LighterNumbersError(`Invalid ${label}: ${JSON.stringify(str)} (expected a plain non-negative decimal)`);
  const intPart = m[1] as string;
  const frac = m[2] ?? '';
  if (frac.length > decimals) {
    throw new LighterNumbersError(`${label} ${str} has ${frac.length} decimals, grid allows ${decimals}`);
  }
  const digits = `${intPart}${frac.padEnd(decimals, '0')}`.replace(/^0+(?=\d)/, '');
  const n = Number(digits);
  if (!Number.isSafeInteger(n))
    throw new LighterNumbersError(`${label} ${str} does not fit a safe integer at ${decimals} decimals`);
  return n;
}

/** Wire `base_amount` for a size that is already on the lot grid: `round(size * 10^sizeDecimals)`, exactly. */
export function sizeToWire(size: number, sizeDecimals: number): number {
  assertFinite(size, 'size');
  if (size < 0) throw new LighterNumbersError(`Invalid size: ${size} (negative)`);
  return scaledIntegerFromString(size.toFixed(sizeDecimals), sizeDecimals, 'size');
}

/**
 * Wire `price` for a price string already on the tick grid (from {@link quantizePrice}). The scale is
 * taken from `priceDecimals` when given, otherwise from the number of digits in the string, which is
 * what the knowledge base does with `pxToStr` output (markets-and-numbers.md §3).
 */
export function priceToWire(priceStr: string, priceDecimals?: number): number {
  const decimals = priceDecimals ?? decimalsOf(priceStr);
  return scaledIntegerFromString(priceStr, decimals, 'price');
}

/** Wire integers for `create_order`: `{ base_amount, price }`. */
export function toWire(
  size: number,
  priceStr: string,
  sizeDecimals: number,
  priceDecimals?: number,
): { base_amount: number; price: number } {
  return { base_amount: sizeToWire(size, sizeDecimals), price: priceToWire(priceStr, priceDecimals) };
}

/** Plain decimal string of an integer scaled by `10^-decimals` (160534, 2 -> `"1605.34"`). */
export function scaledIntegerToString(value: number, decimals: number, label = 'value'): string {
  assertDecimals(decimals, 'decimals');
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new LighterNumbersError(`Invalid ${label}: ${String(value)} (expected a non-negative safe integer)`);
  }
  if (decimals === 0) return String(value);
  const digits = String(value).padStart(decimals + 1, '0');
  return `${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`;
}

/** Size in base units from a wire `base_amount`. */
export function sizeFromWire(baseAmount: number, sizeDecimals: number): number {
  return Number(scaledIntegerToString(baseAmount, sizeDecimals, 'base_amount'));
}

/** Price string on the tick grid from a wire `price`. */
export function priceFromWire(price: number, priceDecimals: number): string {
  return scaledIntegerToString(price, priceDecimals, 'price');
}

/** Inverse of {@link toWire}. */
export function fromWire(
  wire: { base_amount: number; price: number },
  sizeDecimals: number,
  priceDecimals: number,
): { size: number; priceStr: string } {
  return { size: sizeFromWire(wire.base_amount, sizeDecimals), priceStr: priceFromWire(wire.price, priceDecimals) };
}
All files