src/format/size.ts
v0.3.0 · 6.8 KB
import {
HlFormatError,
abs,
add,
cmp,
isPositive,
mul,
parseDec,
pow10,
powTen,
roundRatio,
roundToExp,
strip,
sub,
toNumber,
toPlain,
type Dec,
} from './decimal.js';
import type { Numeric, SizeRounding } from './types.js';
/**
* Upper bound for `szDecimals`. HL currently uses 0..8; the knowledge base
* validates `meta` against this range and treats anything else as a broken
* universe (one bad element invalidates the whole map).
*/
export const MAX_SZ_DECIMALS = 8;
/** Throws `HlFormatError` unless `szDecimals` is an integer in `0..MAX_SZ_DECIMALS`. */
export function assertSzDecimals(szDecimals: number): void {
if (!Number.isSafeInteger(szDecimals) || szDecimals < 0 || szDecimals > MAX_SZ_DECIMALS) {
throw new HlFormatError(`Invalid szDecimals: ${String(szDecimals)} (expected integer 0..${MAX_SZ_DECIMALS})`);
}
}
/** @internal Parses a value that must be >= 0. */
export function parseNonNegative(value: Numeric, field: string): Dec {
const d = parseDec(value, field);
if (d.n < 0n) throw new HlFormatError(`Invalid ${field}: ${String(value)} is negative`);
return d;
}
/** @internal Parses a value that must be > 0. */
export function parsePositive(value: Numeric, field: string): Dec {
const d = parseDec(value, field);
if (!isPositive(d)) throw new HlFormatError(`Invalid ${field}: ${String(value)} must be > 0`);
return d;
}
/** @internal Rounds an exact size to the lot grid. */
export function roundSizeDec(sz: Dec, szDecimals: number, mode: SizeRounding): Dec {
return roundToExp(sz, -szDecimals, mode);
}
/**
* Lot size (size step) of an asset: `10^-szDecimals`, as a canonical string
* (`szStep(2) === '0.01'`, `szStep(0) === '1'`). Use `Number(...)` if you need a number.
*/
export function szStep(szDecimals: number): string {
assertSzDecimals(szDecimals);
return toPlain(powTen(-szDecimals));
}
/**
* Rounds a size to the lot grid and returns the canonical wire string.
*
* Rules (orders.md §5):
* - A size has at most `szDecimals` decimals; the lot is `10^-szDecimals`.
* - Default is `floor`. `ceil` is only for a full reduceOnly close (the exchange
* clamps the fill to the position). Why: ceil on a partial reduce can close the
* whole position (0.2 -> 1 at `szDecimals=0`); floor on a full close can leave
* unclosable dust when float noise drops the last lot (a 0.2500 position closed
* as 0.2499).
* - An epsilon of 1e-9 lot is applied before floor/ceil because
* `0.29 * 100 = 28.999999999999996`: without it a 0.29 take-profit order goes out
* as 0.28 and is then skipped. Planner and executor must call this same function.
* - The math is exact decimal (no float rounding); float artifacts such as
* `0.1 + 0.2` are absorbed by the epsilon.
*
* Returns `'0'` when the size does not reach one lot in the chosen direction;
* callers must treat that as "not expressible" and skip. Throws
* `HlFormatError` on negative, non-finite or malformed input.
*/
export function formatSize(sz: Numeric, szDecimals: number, mode: SizeRounding = 'floor'): string {
assertSzDecimals(szDecimals);
return toPlain(roundSizeDec(parseNonNegative(sz, 'size'), szDecimals, mode));
}
/**
* Size for a USD notional at the ORDER price: `usd / px` rounded to the lot grid
* (default floor). Exact rational math, same epsilon as `formatSize`.
* Does not bump to the $10 minimum; run `planOrderSize` or `meetsMinNotional`
* on the result.
*/
export function sizeFromNotional(usd: Numeric, px: Numeric, szDecimals: number, mode: SizeRounding = 'floor'): string {
assertSzDecimals(szDecimals);
const u = parseNonNegative(usd, 'usd');
const p = parsePositive(px, 'price');
// lots = (u.n / 10^u.s) / (p.n / 10^p.s) * 10^szDecimals
const num = u.n * pow10(p.s + szDecimals);
const den = p.n * pow10(u.s);
const lots = roundRatio(num, den, mode);
return toPlain(strip({ n: lots, s: szDecimals }));
}
/** True if `sz` is a non-negative size with at most `szDecimals` decimals (no rounding applied). */
export function isValidSize(sz: Numeric, szDecimals: number): boolean {
assertSzDecimals(szDecimals);
let d: Dec;
try {
d = parseDec(sz, 'size');
} catch {
return false;
}
return d.n >= 0n && strip(d).s <= szDecimals;
}
/**
* Whether an IoC fill counts as complete (orders.md §7.4):
* `requested <= 0 || filled >= requested - lot`. One lot of tolerance absorbs
* rounding between the requested and the placed size. A partial IoC must not
* delete the position record; the remainder goes to a reduceOnly retry queue.
*/
export function isFullyFilled(requested: Numeric, filled: Numeric, szDecimals: number): boolean {
assertSzDecimals(szDecimals);
const req = parseDec(requested, 'requested');
if (req.n <= 0n) return true;
const got = parseDec(filled, 'filled');
return cmp(got, sub(req, powTen(-szDecimals))) >= 0;
}
/**
* Hidden remainder after a "full" reduceOnly close (orders.md §7.4). reduceOnly
* clamps the fill to the REAL position; if the order was sized from a stale
* snapshot and filled completely, the real position may have been larger.
* Re-read the position via REST and call this: true if
* `|freshSize| > filled + lot`. Exact closes never trigger it.
*/
export function hasHiddenRemainder(freshSize: Numeric, filled: Numeric, szDecimals: number): boolean {
assertSzDecimals(szDecimals);
const fresh = abs(parseDec(freshSize, 'freshSize'));
const got = parseNonNegative(filled, 'filled');
return cmp(fresh, add(got, powTen(-szDecimals))) > 0;
}
/**
* Whether a position read from the exchange is flat on the lot grid:
* `|size| < 10^-szDecimals` (orders.md §8.2). The emergency flatten loop runs until
* this holds; comparing against exact zero never ends when a sub-lot residue (or a
* noisy `"0.0000000001"` string) is reported.
*/
export function isFlatPosition(size: Numeric, szDecimals: number): boolean {
assertSzDecimals(szDecimals);
return cmp(abs(parseDec(size, 'size')), powTen(-szDecimals)) < 0;
}
/** Half a lot plus 1e-12, the snapshot acceptance band from orders.md §13.4. */
const EXPECTED_POSITION_EXTRA: Dec = { n: 1n, s: 12 };
/**
* Whether a freshly read signed position matches the position expected after an
* own write (position before the order plus the confirmed signed fill):
* `|actual - expected| <= 0.5 * lot + 1e-12` (orders.md §13.4).
*
* Why: the info replica lags; a stable snapshot can still predate the IoC that was
* just sent, and acting on it sends the same adjustment a second time. Re-read with
* backoff until this holds instead of trading on the stale view.
*/
export function positionMatchesExpected(actual: Numeric, expected: Numeric, szDecimals: number): boolean {
assertSzDecimals(szDecimals);
const diff = abs(sub(parseDec(actual, 'actual'), parseDec(expected, 'expected')));
const band = add(mul({ n: 5n, s: 1 }, powTen(-szDecimals)), EXPECTED_POSITION_EXTRA);
return cmp(diff, band) <= 0;
}