Skip to content
markpaper

src/format/size.ts

v0.3.0 · 6.8 KB

Download file
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;
}
All files