Skip to content
markpaper

src/orders/size.ts

v0.2.0 · 3.4 KB

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