Skip to content
markpaper

src/orders/size.ts

v0.2.1 · 6.4 KB

Download file
// Size policy for Lighter orders (knowledge base: markets-and-numbers.md §4.3, orders.md §3-4).
//
// ONE function for planner and executor. Rules it enforces:
//   - open / increase / partial reduceOnly: floor to the lot; below EITHER minimum (min_quote_amount in
//     USD, min_base_amount in lots) -> skip, never bump (a bump changes the intended size, for a market
//     with a 5-unit lot by multiples);
//   - full reduceOnly close: ceil to the lot and bump above BOTH minimums at once (the exchange caps a
//     reduceOnly order at the live position, overshoot is impossible; bumping only to the lot minimum
//     can leave a close under $10, rejected on every retry);
//   - optionally, resting orders whose floor ate more than a given tolerance are skipped;
//   - a missing/non-positive price: skip for opens, DEFER for exits (retry next cycle, do not mark the exit as done).

import { meetsMinimums } from '../numbers/minimums.js';
import { quantizeSize, sizeLot } from '../numbers/quantize.js';

export type OrderIntent = 'open' | 'increase' | 'partialReduce' | 'fullClose';
export type TimeInForce = 'Gtc' | 'Ioc';

export interface PlanOrderSizeInput {
  intent: OrderIntent;
  /** Desired absolute size in base units (for `fullClose`: the absolute position size). */
  size: number;
  /** ORDER price: for an IoC the crossed limit, not the mark. The minimum is checked at this price. */
  px: number;
  /** `supported_size_decimals` of the market. */
  sizeDecimals: number;
  /** `min_base_amount` of the market (base units). Omit / 0 = none. */
  minBaseAmount?: number;
  /** `min_quote_amount` of the market (USD). Omit / 0 = none. */
  minQuoteUsd?: number;
  /** Default `'Gtc'`. IoC never rests, so the lot-error tolerance does not apply to it. */
  tif?: TimeInForce;
  /**
   * Resting orders only: if flooring removed more than this percentage of the requested size, skip
   * (the placed order would differ materially from the requested one). Omit = no check.
   */
  sizeTolerancePct?: number;
}

export type SizeSkipReason =
  | 'size_rounds_to_zero'
  | 'below_min_base_amount'
  | 'below_min_quote_amount'
  | 'lot_error_above_tolerance'
  | 'nothing_to_close';

export type SizePlan =
  | {
      action: 'place';
      /** Size on the lot grid. */
      size: number;
      /** `size.toFixed(sizeDecimals)`. */
      sizeStr: string;
      /** `size * px` (float; use `numbers.meetsMinimumsExact` on the wire values for the last gate). */
      notional: number;
      /** True if a full close was raised above ceil(size) to reach the minimums. */
      bumped: boolean;
      /** True for `partialReduce` and `fullClose`. */
      reduceOnly: boolean;
    }
  | { action: 'skip'; reason: SizeSkipReason; size: number; notional: number; detail: string }
  /** No usable price: `skip` for `open` / `increase`; `defer` for exits (retry next cycle, do not treat the exit as done). */
  | { action: 'skip' | 'defer'; reason: 'no_reference_price' };

const EPS = 1e-9;

/** Smallest lot multiple >= `value` (float-safe). */
function ceilToLot(value: number, lot: number, sizeDecimals: number): number {
  return Number((Math.ceil(value / lot - EPS) * lot).toFixed(sizeDecimals));
}

export function planOrderSize(input: PlanOrderSizeInput): SizePlan {
  const { intent, sizeDecimals } = input;
  if (intent !== 'open' && intent !== 'increase' && intent !== 'partialReduce' && intent !== 'fullClose') {
    throw new RangeError(`Invalid intent: ${String(intent)}`);
  }
  const lot = sizeLot(sizeDecimals);
  const minBase = input.minBaseAmount && input.minBaseAmount > 0 ? input.minBaseAmount : 0;
  const minQuote = input.minQuoteUsd && input.minQuoteUsd > 0 ? input.minQuoteUsd : 0;
  const reduceOnly = intent === 'partialReduce' || intent === 'fullClose';
  const tif = input.tif ?? 'Gtc';
  const px = typeof input.px === 'number' && Number.isFinite(input.px) && input.px > 0 ? input.px : null;
  const requested = typeof input.size === 'number' && Number.isFinite(input.size) && input.size > 0 ? input.size : 0;

  if (intent === 'fullClose') {
    if (requested <= 0)
      return { action: 'skip', reason: 'nothing_to_close', size: 0, notional: 0, detail: 'position is flat' };
    if (px === null) return { action: 'defer', reason: 'no_reference_price' };
    const ceiled = Math.max(lot, quantizeSize(requested, sizeDecimals, 'ceil'));
    // Above BOTH minimums at once: max(ceil(size), min_base_amount, lots worth min_quote at px).
    const lotsForQuote = minQuote > 0 ? Math.ceil(minQuote / (px * lot) - EPS) * lot : 0;
    let placeable = ceilToLot(Math.max(ceiled, minBase, lotsForQuote), lot, sizeDecimals);
    if (minQuote > 0 && placeable * px < minQuote - EPS) placeable = Number((placeable + lot).toFixed(sizeDecimals)); // 9.999999999999998
    return {
      action: 'place',
      size: placeable,
      sizeStr: placeable.toFixed(sizeDecimals),
      notional: placeable * px,
      bumped: placeable > ceiled + EPS,
      reduceOnly,
    };
  }

  if (px === null) return { action: reduceOnly ? 'defer' : 'skip', reason: 'no_reference_price' };
  const floored = quantizeSize(requested, sizeDecimals, 'floor');
  const notional = floored * px;
  if (floored <= 0)
    return { action: 'skip', reason: 'size_rounds_to_zero', size: 0, notional: 0, detail: `${requested} < lot ${lot}` };

  if (tif === 'Gtc' && input.sizeTolerancePct !== undefined && requested > 0) {
    const errPct = ((requested - floored) / requested) * 100;
    if (errPct > input.sizeTolerancePct + EPS) {
      return {
        action: 'skip',
        reason: 'lot_error_above_tolerance',
        size: floored,
        notional,
        detail: `floor lost ${errPct.toFixed(2)}% > ${input.sizeTolerancePct}%`,
      };
    }
  }

  const check = meetsMinimums({ notional, baseAmount: floored, minBaseAmount: minBase, minQuoteUsd: minQuote });
  if (!check.ok) {
    // When both fail, name the lot minimum: it is the one a dollar-only gate hides (code 21706).
    if (check.failing.includes('min_base_amount')) {
      return {
        action: 'skip',
        reason: 'below_min_base_amount',
        size: floored,
        notional,
        detail: `${floored} < min_base_amount ${minBase}`,
      };
    }
    return {
      action: 'skip',
      reason: 'below_min_quote_amount',
      size: floored,
      notional,
      detail: `$${notional.toFixed(2)} < $${minQuote}`,
    };
  }
  return {
    action: 'place',
    size: floored,
    sizeStr: floored.toFixed(sizeDecimals),
    notional,
    bumped: false,
    reduceOnly,
  };
}
All files