src/orders/size.ts
v0.2.0 · 3.4 KB
// Size planning: quantization, two minimums, full-close bump.
//
// `min_size` ($100) is enforced by the venue on RESTING orders: resting orders below it were not
// observed, while taker IOCs (reduce-only IOCs too) below `min_size` were observed to fill.
// So: two minimums, resting = `min_size`, IOC = resting by default and lowered only after measuring the
// floor with a margin. Opens and partial reductions below the minimum are SKIPPED (bumping them changes
// the position size). A FULL reduce-only close is never gated: it is ceiled to the lot and bumped above
// the minimum, and the venue cuts it at the live position - or answers 2064, handled by the caller with
// one retry at the exact position size.
import { minSizeForNotional, type Numeric, quantizeSize, type SizeRounding } from '../numbers/quant.js';
import { notionalX18 } from '../numbers/x18.js';
import type { OrderIntent } from './build.js';
/** Inputs of {@link planOrderSize}. */
export interface PlanOrderSizeInput {
/** Desired absolute size (double, decimal string or x18 bigint). */
size: Numeric;
/** Price the order will carry (x18), for the notional check. */
priceX18: bigint;
lotX18: bigint;
intent: OrderIntent;
reduceOnly: boolean;
/** True for a FULL reduce-only close (the whole position). */
fullClose?: boolean;
/** Resting minimum notional, x18 (`product.minSizeX18`). */
minRestingNotionalX18: bigint;
/** IOC/FOK minimum notional, x18. Default: the resting minimum. Lower it only after measuring the floor. */
minTakerNotionalX18?: bigint;
}
/** Decision of {@link planOrderSize}. */
export type SizePlan =
| {
readonly action: 'send';
readonly sizeX18: bigint;
readonly notionalX18: bigint;
readonly rounding: SizeRounding;
/** True when a sub-minimum full close was raised to the minimum. */
readonly bumped: boolean;
readonly minNotionalX18: bigint;
}
| {
readonly action: 'skip';
readonly reason: 'zero-size' | 'below-minimum';
readonly sizeX18: bigint;
readonly notionalX18: bigint;
readonly minNotionalX18: bigint;
};
/** Plans the size of one order (see the module header). */
export function planOrderSize(input: PlanOrderSizeInput): SizePlan {
const fullClose = input.fullClose === true && input.reduceOnly;
const rounding: SizeRounding = fullClose ? 'ceil' : 'floor';
const isTaker = input.intent !== 'resting';
const minNotionalX18 = isTaker
? (input.minTakerNotionalX18 ?? input.minRestingNotionalX18)
: input.minRestingNotionalX18;
if (minNotionalX18 < 0n) throw new RangeError('minimum notional must not be negative');
if (input.priceX18 <= 0n) throw new RangeError('priceX18 must be positive');
let sizeX18 = quantizeSize(input.size, input.lotX18, rounding);
if (sizeX18 <= 0n) return { action: 'skip', reason: 'zero-size', sizeX18: 0n, notionalX18: 0n, minNotionalX18 };
let notional = notionalX18(input.priceX18, sizeX18);
let bumped = false;
if (notional < minNotionalX18) {
if (!fullClose) return { action: 'skip', reason: 'below-minimum', sizeX18, notionalX18: notional, minNotionalX18 };
const bumpedSize = minSizeForNotional(input.priceX18, minNotionalX18, input.lotX18);
if (bumpedSize > sizeX18) {
sizeX18 = bumpedSize;
notional = notionalX18(input.priceX18, sizeX18);
bumped = true;
}
}
return { action: 'send', sizeX18, notionalX18: notional, rounding, bumped, minNotionalX18 };
}