src/orders/size.ts
v0.2.1 · 6.4 KB
// 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,
};
}