Skip to content
markpaper

src/numbers/minimums.ts

v0.2.1 · 5.5 KB

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