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