Skip to content
markpaper

src/format/validate.ts

v0.3.0 · 5 KB

Download file
import { HlFormatError, cmp, isPositive, mul, parseDec, toPlain, type Dec } from './decimal.js';
import { MIN_ORDER_NOTIONAL_USD } from './notional.js';
import { isValidPrice } from './price.js';
import { assertSzDecimals, isValidSize } from './size.js';
import type { Numeric, PriceGridOptions } from './types.js';

export type OrderIssue =
  | 'invalid_price'
  | 'invalid_trigger_price'
  | 'invalid_size'
  | 'zero_size'
  | 'below_min_notional'
  | 'full_close_requires_reduce_only'
  | 'position_tpsl_requires_reduce_only'
  | 'position_tpsl_size_must_be_zero';

export interface ValidateOrderInput extends PriceGridOptions {
  /** Price exactly as it will be sent (`p`). */
  px: Numeric;
  /** Size exactly as it will be sent (`s`). */
  sz: Numeric;
  /** `r` flag. */
  reduceOnly?: boolean;
  /** The order closes the whole position; exempts it from the minimum notional. Requires `reduceOnly`. */
  fullClose?: boolean;
  /** Order is part of a `grouping: 'positionTpsl'` pair (`s: '0'`, `r: true`). */
  positionTpsl?: boolean;
  /**
   * `t.trigger.triggerPx` of a trigger (TP/SL) order, exactly as it will be sent.
   * It is checked against the same price grid as `p`.
   */
  triggerPx?: Numeric;
  /** Default $10. */
  minNotionalUsd?: number;
}

export interface OrderValidation {
  ok: boolean;
  issues: OrderIssue[];
  /** Canonical wire price, when the price is valid. */
  p?: string;
  /** Canonical wire size, when the size is valid. */
  s?: string;
  /** Exact `p * s`, when both are valid. */
  notional?: string;
}

/**
 * Pre-send check of an already formatted order, without rounding anything
 * (orders.md §1, §3, §5, §6). Reports every issue instead of throwing:
 *
 * - `invalid_price`: not on the price grid (`Order has invalid price`).
 * - `invalid_trigger_price`: `triggerPx` given but not on the price grid.
 * - `invalid_size`: more than `szDecimals` decimals, negative or malformed.
 * - `zero_size`: size 0 on a normal order (only positionTpsl uses `s: '0'`).
 * - `below_min_notional`: `p * s < $10` for opens and partial reduces. A full
 *   reduceOnly close is exempt: gating it leaves dust positions open forever.
 * - `full_close_requires_reduce_only`: the min-notional exemption and ceil sizing
 *   are only safe because reduceOnly clamps the fill; a plain order larger than the
 *   position flips it.
 * - `position_tpsl_*`: positionTpsl legs need `s: '0'` (tracks the whole position)
 *   and `r: true`.
 */
export function validateOrder(input: ValidateOrderInput): OrderValidation {
  assertSzDecimals(input.szDecimals);
  const min = input.minNotionalUsd ?? MIN_ORDER_NOTIONAL_USD;
  if (typeof min !== 'number' || !Number.isFinite(min) || min < 0) {
    throw new HlFormatError(`Invalid minNotionalUsd: ${String(min)}`);
  }
  const issues: OrderIssue[] = [];
  const out: OrderValidation = { ok: false, issues };

  let price: Dec | null = null;
  if (isValidPrice(input.px, input)) {
    price = parseDec(input.px, 'price');
    out.p = toPlain(price);
  } else {
    issues.push('invalid_price');
  }
  if (input.triggerPx !== undefined && !isValidPrice(input.triggerPx, input)) issues.push('invalid_trigger_price');

  let size: Dec | null = null;
  if (isValidSize(input.sz, input.szDecimals)) {
    size = parseDec(input.sz, 'size');
    out.s = toPlain(size);
  } else {
    issues.push('invalid_size');
  }

  if (input.positionTpsl) {
    if (input.reduceOnly !== true) issues.push('position_tpsl_requires_reduce_only');
    if (size && isPositive(size)) issues.push('position_tpsl_size_must_be_zero');
  } else {
    if (input.fullClose && input.reduceOnly !== true) issues.push('full_close_requires_reduce_only');
    if (size && !isPositive(size)) issues.push('zero_size');
    const exempt = input.fullClose === true && input.reduceOnly === true;
    if (price && size && isPositive(size)) {
      const value = mul(price, size);
      out.notional = toPlain(value);
      if (!exempt && cmp(value, parseDec(min, 'minNotionalUsd')) < 0) issues.push('below_min_notional');
    }
  }

  out.ok = issues.length === 0;
  return out;
}

/**
 * Matches a trigger price read back from `frontendOpenOrders` against the price
 * that was sent: `|actual - target| <= max(target * 1e-5, 1e-9)` (orders.md §4.2).
 * Why: positionTpsl placement returns no oid, so the order is found by matching,
 * and HL may return the price in a normalized notation. Float comparison is
 * intended here: this is a tolerance match, not a wire value.
 *
 * Both prices must be positive: a plain limit order reads back `triggerPx: "0.0"`
 * (truthy in JS), and an empty string would otherwise coerce to 0; neither may
 * match a trigger.
 */
export function triggerPxMatches(actual: Numeric, target: Numeric): boolean {
  if (typeof actual === 'string' && actual.trim() === '') return false;
  if (typeof target === 'string' && target.trim() === '') return false;
  const a = Number(actual);
  const t = Number(target);
  if (!Number.isFinite(a) || !Number.isFinite(t) || a <= 0 || t <= 0) return false;
  return Math.abs(a - t) <= Math.max(Math.abs(t) * 1e-5, 1e-9);
}
All files