src/numbers/minimums.ts
v0.2.1 · 5.5 KB
// Two independent order minimums of a Lighter market (knowledge base: markets-and-numbers.md §4).
//
// - min_quote_amount: order value in USD ($10 on the robinhoodchain instance), checked at the order
// price AFTER size quantization;
// - min_base_amount: lot minimum in base units, error code 21706 when violated. It is independent of
// the dollar one and bites in both directions (observed 2026-08-20..23: one market's 0.01 lots were
// worth ~$16, stricter than $10; another's 0.04 lots ~$3.6, weaker).
//
// An order that passes the dollar gate can still be rejected by the lot gate (21706): a dollar-only gate
// hides the lot minimum, and so does an executor that quantizes on its own. Check both, before sending,
// in one place.
import { LighterNumbersError } from './errors.js';
import { assertDecimals } from './quantize.js';
export type MinimumKind = 'min_quote_amount' | 'min_base_amount';
export interface MinimumsInput {
/** Order value in USD at the ORDER price, after quantization: `size * price`. */
notional: number;
/** Order size in base units (same scale as `min_base_amount` from `orderBookDetails`, NOT the wire integer). */
baseAmount: number;
/** `min_base_amount` of the market in base units; `undefined` / 0 = no lot minimum. */
minBaseAmount?: number;
/** `min_quote_amount` of the market in USD; `undefined` / 0 = no dollar minimum. */
minQuoteUsd?: number;
}
export interface MinimumsCheck {
ok: boolean;
/** Which minimums fail; empty when `ok`. */
failing: MinimumKind[];
notional: number;
baseAmount: number;
}
/** Absolute slack for the float product `size * price` (9.999999999999998 for an exact $10). */
const NOTIONAL_EPSILON = 1e-9;
/** Absolute slack for base-unit comparisons (sizes have at most 12 decimals). */
const BASE_EPSILON = 1e-12;
function nonNegative(value: number | undefined, label: string): number {
if (value === undefined) return 0;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new LighterNumbersError(`Invalid ${label}: ${String(value)}`);
}
return value;
}
/**
* Checks BOTH minimums of an order. Pass the quantized size and the notional at the order price.
* Opens and partial reduceOnly orders below either minimum are skipped, never bumped; a full
* reduceOnly close is bumped above both (see `orders.planOrderSize`).
*/
export function meetsMinimums(input: MinimumsInput): MinimumsCheck {
const notional = nonNegative(input.notional, 'notional');
const baseAmount = nonNegative(input.baseAmount, 'baseAmount');
const minBase = nonNegative(input.minBaseAmount, 'minBaseAmount');
const minQuote = nonNegative(input.minQuoteUsd, 'minQuoteUsd');
const failing: MinimumKind[] = [];
if (minQuote > 0 && notional + NOTIONAL_EPSILON < minQuote) failing.push('min_quote_amount');
if (minBase > 0 && baseAmount + BASE_EPSILON < minBase) failing.push('min_base_amount');
return { ok: failing.length === 0, failing, notional, baseAmount };
}
export interface WireMinimumsInput {
/** Wire `base_amount` (integer, `size * 10^sizeDecimals`). */
baseAmount: number;
/** Wire `price` (integer, `price * 10^priceDecimals`). */
price: number;
sizeDecimals: number;
priceDecimals: number;
/** `min_base_amount` as the exchange sends it (`"0.0100"`). */
minBaseAmount?: string;
/** `min_quote_amount` as the exchange sends it (`"10.000000"`). */
minQuoteUsd?: string;
}
function parseScaled(str: string, label: string): { digits: bigint; decimals: number } {
const m = /^(\d+)(?:\.(\d*))?$/.exec(str.trim());
if (!m) throw new LighterNumbersError(`Invalid ${label}: ${JSON.stringify(str)}`);
const frac = m[2] ?? '';
return { digits: BigInt(`${m[1]}${frac}`), decimals: frac.length };
}
/**
* Exact variant of {@link meetsMinimums} on wire integers, with integer arithmetic only: no float
* product, no epsilon. Use it as the last gate before `create_order` when the float check is borderline.
*/
export function meetsMinimumsExact(input: WireMinimumsInput): MinimumsCheck {
assertDecimals(input.sizeDecimals, 'sizeDecimals');
assertDecimals(input.priceDecimals, 'priceDecimals');
if (!Number.isSafeInteger(input.baseAmount) || input.baseAmount < 0) {
throw new LighterNumbersError(`Invalid baseAmount: ${String(input.baseAmount)}`);
}
if (!Number.isSafeInteger(input.price) || input.price < 0) {
throw new LighterNumbersError(`Invalid price: ${String(input.price)}`);
}
const failing: MinimumKind[] = [];
const base = BigInt(input.baseAmount);
const px = BigInt(input.price);
// notional = base * px / 10^(sizeDecimals + priceDecimals)
if (input.minQuoteUsd !== undefined) {
const minQ = parseScaled(input.minQuoteUsd, 'minQuoteUsd');
// base*px * 10^minQ.decimals >= minQ.digits * 10^(sizeDecimals + priceDecimals)
const lhs = base * px * 10n ** BigInt(minQ.decimals);
const rhs = minQ.digits * 10n ** BigInt(input.sizeDecimals + input.priceDecimals);
if (minQ.digits > 0n && lhs < rhs) failing.push('min_quote_amount');
}
if (input.minBaseAmount !== undefined) {
const minB = parseScaled(input.minBaseAmount, 'minBaseAmount');
// base * 10^minB.decimals >= minB.digits * 10^sizeDecimals
const lhs = base * 10n ** BigInt(minB.decimals);
const rhs = minB.digits * 10n ** BigInt(input.sizeDecimals);
if (minB.digits > 0n && lhs < rhs) failing.push('min_base_amount');
}
const notional = (input.baseAmount * input.price) / 10 ** (input.sizeDecimals + input.priceDecimals);
return { ok: failing.length === 0, failing, notional, baseAmount: input.baseAmount / 10 ** input.sizeDecimals };
}