src/numbers/quantize.ts
v0.2.1 · 8.3 KB
// 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) };
}