Skip to content
markpaper

src/format/notional.ts

v0.3.0 · 9.3 KB

Download file
import {
  HlFormatError,
  abs,
  ceilDivDec,
  roundRatio,
  pow10,
  cmp,
  isPositive,
  mul,
  parseDec,
  powTen,
  roundToExp,
  strip,
  toPlain,
  type Dec,
} from './decimal.js';
import { assertSzDecimals, parseNonNegative, parsePositive, roundSizeDec } from './size.js';
import type { Numeric } from './types.js';

/**
 * Exchange minimum order value in USD on main perps and HIP-3 dexes:
 * `px * sz >= 10`, at the ORDER price, after size rounding.
 * Reject text: `Order must have minimum value of $10.`
 */
export const MIN_ORDER_NOTIONAL_USD = 10;

/**
 * Recommended floor for resting limit orders sized from a USD amount: `min * 1.05` (the 5%
 * margin is this kit's choice, not an exchange number). Why: a bid below mid plus a floored
 * size pushes an order sized "exactly $10" under the minimum and it is rejected.
 */
export const RECOMMENDED_MIN_QUOTE_NOTIONAL_USD = 10.5;

function parseMin(minNotionalUsd: number): Dec {
  if (typeof minNotionalUsd !== 'number' || !Number.isFinite(minNotionalUsd) || minNotionalUsd < 0) {
    throw new HlFormatError(`Invalid minNotionalUsd: ${String(minNotionalUsd)}`);
  }
  return parseDec(minNotionalUsd, 'minNotionalUsd');
}

/** Exact order value `px * sz` as a canonical decimal string (no float error such as 9.999999999999998). */
export function notional(px: Numeric, sz: Numeric): string {
  return toPlain(mul(abs(parseDec(px, 'price')), abs(parseDec(sz, 'size'))));
}

/**
 * Exact check `px * sz >= minNotionalUsd` (default $10).
 * Pass the FORMATTED price and size (what goes on the wire): checking before
 * rounding lets an increment worth just over $10 floor below the minimum, and the
 * exchange then rejects it on every attempt. A full reduceOnly close is exempt; use
 * {@link planOrderSize} to get that exemption applied.
 */
export function meetsMinNotional(px: Numeric, sz: Numeric, minNotionalUsd: number = MIN_ORDER_NOTIONAL_USD): boolean {
  const value = mul(parseNonNegative(px, 'price'), parseNonNegative(sz, 'size'));
  return cmp(value, parseMin(minNotionalUsd)) >= 0;
}

/**
 * Size for a FULL reduceOnly close: ceil to the lot, then raise to the smallest
 * number of lots whose value at `px` reaches the minimum (orders.md §6.3).
 *
 * Why: one extra lot is not always enough (0.001 ETH @ $3000 at `szDecimals=3`
 * needs 0.004 = $12; a one-lot bump to 0.002 = $6 is rejected forever), and a
 * close limited by the minimum leaves dust forever. The exchange clamps a
 * reduceOnly fill to the live position, so the oversize is harmless.
 *
 * Never use this for opens (at $8 a lot, a $10 open floors to one lot and a
 * one-lot bump makes it $16, +60%) or partial reduces (it can close more than
 * intended). Exact math, so no
 * 9.999999999999998 guard is needed.
 */
export function bumpToMinNotional(
  sz: Numeric,
  px: Numeric,
  szDecimals: number,
  minNotionalUsd: number = MIN_ORDER_NOTIONAL_USD,
): string {
  assertSzDecimals(szDecimals);
  const size = parsePositive(sz, 'size');
  const price = parsePositive(px, 'price');
  return toPlain(bumpDec(size, price, szDecimals, parseMin(minNotionalUsd)));
}

function bumpDec(size: Dec, price: Dec, szDecimals: number, min: Dec): Dec {
  const lot = powTen(-szDecimals);
  const ceiled = roundSizeDec(size, szDecimals, 'ceil');
  const lotsForMin = ceilDivDec(min, mul(price, lot));
  const bumped = strip({ n: lotsForMin, s: szDecimals });
  return cmp(bumped, ceiled) > 0 ? bumped : ceiled;
}

/**
 * What an order is for; decides rounding direction and the min-notional policy
 * (orders.md §5.4):
 *
 * | intent          | rounding | bump to minimum |
 * |-----------------|----------|-----------------|
 * | `open`          | floor    | never, skip     |
 * | `increase`      | floor    | never, skip     |
 * | `partialReduce` | floor    | never, skip     |
 * | `fullClose`     | ceil     | yes, in lots    |
 */
export type OrderIntent = 'open' | 'increase' | 'partialReduce' | 'fullClose';

export interface PlanOrderSizeInput {
  intent: OrderIntent;
  /** Desired absolute size in base units (for `fullClose`: the absolute position size). */
  size: Numeric;
  /**
   * ORDER price for every intent, `fullClose` included: for an IoC pass the
   * slippage-adjusted limit (`marketLimitPrice`), not the mid. The exchange checks
   * the minimum at the order price, so a close bumped to $10 at mid and sent as a
   * sell 10% below mid is worth $9 on the wire and is rejected on every retry.
   * A missing / non-positive price yields `no_reference_price`.
   */
  px: Numeric;
  szDecimals: number;
  /** Default {@link MIN_ORDER_NOTIONAL_USD}. Never set it below the real exchange minimum. */
  minNotionalUsd?: number;
}

export type SizeSkipReason = 'size_rounds_to_zero' | 'order_below_min_after_rounding' | 'nothing_to_close';

export type SizePlan =
  | {
      action: 'place';
      /** Wire size string. */
      sz: string;
      /** Exact `px * sz`. */
      notional: string;
      /** True if a full close was raised above ceil(size) to reach the minimum. */
      bumped: boolean;
      /** True for `partialReduce` and `fullClose`: send with `r: true`. */
      reduceOnly: boolean;
    }
  | { action: 'skip'; reason: SizeSkipReason; sz: string; notional: string }
  /**
   * No usable price: `skip` for `open` / `increase`; `defer` for any exit
   * (`partialReduce`, `fullClose`): retry next cycle and do not mark the exit as done.
   */
  | { action: 'skip' | 'defer'; reason: 'no_reference_price' };

/**
 * Decides the wire size of an order, applying the rounding direction and the
 * $10 minimum AFTER rounding at the order price (orders.md §5.4, §6).
 *
 * - `open` / `increase` / `partialReduce`: floor; skip if zero lots or below the
 *   minimum. Examples: partial RO `0.2 @ szDecimals=0` -> skip;
 *   `0.001 @ $5000` ($5) -> skip; `0.29 @ $35, szDecimals=2` ($10.15) -> place 0.29.
 * - `fullClose`: ceil and bump to the minimum in lots; never skipped by the
 *   minimum. Why: a min-notional gate on the full close leaves a sub-$10
 *   remainder open forever.
 * - A missing/non-positive price yields `no_reference_price`: `skip` for opens and
 *   increases, `defer` for every exit. Why: a partial reduce gated at price 0
 *   (`sz * 0 < min`) is dropped and the reduction is lost for good.
 *
 * Re-run after every resize (book-depth downsizing, leverage scaling): a resized
 * size that is not re-floored and re-checked comes back `REJECTED`.
 */
export function planOrderSize(input: PlanOrderSizeInput): SizePlan {
  const { intent, szDecimals } = input;
  assertSzDecimals(szDecimals);
  if (intent !== 'open' && intent !== 'increase' && intent !== 'partialReduce' && intent !== 'fullClose') {
    throw new HlFormatError(`Invalid intent: ${String(intent)}`);
  }
  const min = parseMin(input.minNotionalUsd ?? MIN_ORDER_NOTIONAL_USD);
  const size = parseNonNegative(input.size, 'size');
  const reduceOnly = intent === 'partialReduce' || intent === 'fullClose';

  let price: Dec | null = null;
  try {
    const p = parseDec(input.px, 'price');
    price = isPositive(p) ? p : null;
  } catch {
    price = null;
  }

  if (intent === 'fullClose') {
    if (!isPositive(size)) return { action: 'skip', reason: 'nothing_to_close', sz: '0', notional: '0' };
    if (price === null) return { action: 'defer', reason: 'no_reference_price' };
    const ceiled = roundSizeDec(size, szDecimals, 'ceil');
    const placed = bumpDec(size, price, szDecimals, min);
    return {
      action: 'place',
      sz: toPlain(placed),
      notional: toPlain(mul(placed, price)),
      bumped: cmp(placed, ceiled) > 0,
      reduceOnly,
    };
  }

  if (price === null) return { action: reduceOnly ? 'defer' : 'skip', reason: 'no_reference_price' };
  const floored = roundToExp(size, -szDecimals, 'floor');
  const value = mul(floored, price);
  if (!isPositive(floored)) {
    return { action: 'skip', reason: 'size_rounds_to_zero', sz: '0', notional: '0' };
  }
  if (cmp(value, min) < 0) {
    return { action: 'skip', reason: 'order_below_min_after_rounding', sz: toPlain(floored), notional: toPlain(value) };
  }
  return { action: 'place', sz: toPlain(floored), notional: toPlain(value), bumped: false, reduceOnly };
}

/**
 * Size of a resting limit order for a USD amount at the order price: floor to the
 * lot, and if that is worth less than `minNotionalUsd` (default
 * {@link RECOMMENDED_MIN_QUOTE_NOTIONAL_USD}, $10.50) raise it to the smallest
 * number of lots that reaches it (pitfalls.md §1.2, orders.md §6.2).
 *
 * Why: an order sized $10 at a bid below mid floors under $10 and is rejected
 * every tick. Only where the order size is a free parameter; never for opens or
 * partial reduces: use {@link planOrderSize}, which skips instead of bumping.
 * Returns `'0'` for a non-positive amount.
 */
export function quoteSizeFromNotional(
  usd: Numeric,
  px: Numeric,
  szDecimals: number,
  minNotionalUsd: number = RECOMMENDED_MIN_QUOTE_NOTIONAL_USD,
): string {
  assertSzDecimals(szDecimals);
  const u = parseDec(usd, 'usd');
  const price = parsePositive(px, 'price');
  const min = parseMin(minNotionalUsd);
  if (!isPositive(u)) return '0';
  const lot = powTen(-szDecimals);
  // lots = (u.n / 10^u.s) / (p.n / 10^p.s) * 10^szDecimals
  const floorLots = roundRatio(u.n * pow10(price.s + szDecimals), price.n * pow10(u.s), 'floor');
  const minLots = ceilDivDec(min, mul(price, lot));
  return toPlain(strip({ n: floorLots > minLots ? floorLots : minLots, s: szDecimals }));
}
All files