src/format/validate.ts
v0.3.0 · 5 KB
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);
}