Skip to content
markpaper

src/risk/decimal.ts

v0.3.0 · 5.8 KB

Download file
// Private exact decimal arithmetic for the risk module.
//
// Hyperliquid returns prices, sizes, fees and PnL as decimal strings. Summing
// thousands of them (or multiplying px × sz × rate) in binary floating point
// accumulates drift (e.g. 1.5 * 0.6 === 0.8999999999999999). Every value here is
// an exact `coefficient × 10^-exponent` backed by BigInt; conversion to a JS
// number happens once, at the very end.
//
// This is intentionally a private copy (the module must not depend on other
// modules of the kit).

/** Exact decimal: value = c × 10^(-e), with e >= 0. */
export interface Dec {
  readonly c: bigint;
  readonly e: number;
}

export type DecInput = string | number | bigint | Dec;

const DEC_RE = /^([+-])?(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
/** Guard against pathological exponents such as "1e999999999". */
const MAX_ABS_EXPONENT = 400;

const ZERO: Dec = Object.freeze({ c: 0n, e: 0 });

function isDec(x: unknown): x is Dec {
  return typeof x === 'object' && x !== null && typeof (x as Dec).c === 'bigint' && typeof (x as Dec).e === 'number';
}

function normalize(c: bigint, e: number): Dec {
  if (c === 0n) return ZERO;
  while (e > 0 && c % 10n === 0n) {
    c /= 10n;
    e -= 1;
  }
  return { c, e };
}

/**
 * Parses a decimal string / finite number / bigint into an exact decimal.
 * Accepts plain ("-0.0016", "80", ".5") and exponent ("1e-7") notation.
 * @throws RangeError on empty, malformed or non-finite input.
 */
export function dec(x: DecInput): Dec {
  if (isDec(x)) return x;
  if (typeof x === 'bigint') return normalize(x, 0);
  let s: string;
  if (typeof x === 'number') {
    if (!Number.isFinite(x)) throw new RangeError(`not a finite number: ${x}`);
    // String() yields the shortest round-trip representation, e.g. 0.00015 -> "0.00015".
    s = String(x);
  } else if (typeof x === 'string') {
    s = x.trim();
  } else {
    throw new RangeError(`not a decimal: ${String(x)}`);
  }
  const m = DEC_RE.exec(s);
  const intPart = m?.[2] ?? '';
  const fracPart = m?.[3] ?? '';
  if (!m || intPart.length + fracPart.length === 0) throw new RangeError(`not a decimal: "${s}"`);
  const exp = m[4] !== undefined ? Number.parseInt(m[4], 10) : 0;
  if (Math.abs(exp) > MAX_ABS_EXPONENT) throw new RangeError(`exponent out of range: "${s}"`);
  let c = BigInt(intPart + fracPart || '0');
  if (m[1] === '-') c = -c;
  let e = fracPart.length - exp;
  if (e < 0) {
    c *= 10n ** BigInt(-e);
    e = 0;
  }
  return normalize(c, e);
}

/** Like {@link dec} but returns `null` instead of throwing. */
export function tryDec(x: unknown): Dec | null {
  if (x === null || x === undefined) return null;
  try {
    return dec(x as DecInput);
  } catch {
    return null;
  }
}

function align(a: Dec, b: Dec): [bigint, bigint, number] {
  if (a.e === b.e) return [a.c, b.c, a.e];
  if (a.e > b.e) return [a.c, b.c * 10n ** BigInt(a.e - b.e), a.e];
  return [a.c * 10n ** BigInt(b.e - a.e), b.c, b.e];
}

export function decAdd(a: DecInput, b: DecInput): Dec {
  const [x, y, e] = align(dec(a), dec(b));
  return normalize(x + y, e);
}

export function decSub(a: DecInput, b: DecInput): Dec {
  const [x, y, e] = align(dec(a), dec(b));
  return normalize(x - y, e);
}

export function decMul(a: DecInput, b: DecInput): Dec {
  const x = dec(a);
  const y = dec(b);
  return normalize(x.c * y.c, x.e + y.e);
}

export function decNeg(a: DecInput): Dec {
  const x = dec(a);
  return normalize(-x.c, x.e);
}

export function decAbs(a: DecInput): Dec {
  const x = dec(a);
  return x.c < 0n ? normalize(-x.c, x.e) : x;
}

/**
 * Division rounded half away from zero to `scale` fractional digits (default 20,
 * beyond double precision): 3.3 / 3 = 1.1 exactly, where floats give 1.0999999999999999.
 * @throws RangeError on division by zero.
 */
export function decDiv(a: DecInput, b: DecInput, scale = 20): Dec {
  if (!Number.isInteger(scale) || scale < 0) throw new RangeError(`scale must be a non-negative integer: ${scale}`);
  const x = dec(a);
  const y = dec(b);
  if (y.c === 0n) throw new RangeError('division by zero');
  // x / y = (x.c / y.c) × 10^(y.e − x.e); compute q = round(x.c × 10^k / y.c) with value q × 10^-scale.
  const k = scale + y.e - x.e;
  let num = x.c;
  let den = y.c;
  if (k >= 0) num *= 10n ** BigInt(k);
  else den *= 10n ** BigInt(-k);
  const neg = num < 0n !== den < 0n;
  if (num < 0n) num = -num;
  if (den < 0n) den = -den;
  let q = num / den;
  if ((num % den) * 2n >= den) q += 1n;
  return normalize(neg ? -q : q, scale);
}

/** Multiplies by 10^k exactly (k may be negative): shift(1.5, -4) = 0.00015. */
export function decShift(a: DecInput, k: number): Dec {
  if (!Number.isInteger(k)) throw new RangeError(`shift must be an integer: ${k}`);
  const x = dec(a);
  const e = x.e - k;
  if (e >= 0) return normalize(x.c, e);
  return normalize(x.c * 10n ** BigInt(-e), 0);
}

export function decSign(a: DecInput): -1 | 0 | 1 {
  const x = dec(a);
  return x.c === 0n ? 0 : x.c < 0n ? -1 : 1;
}

export function decCmp(a: DecInput, b: DecInput): -1 | 0 | 1 {
  const [x, y] = align(dec(a), dec(b));
  return x === y ? 0 : x < y ? -1 : 1;
}

export function decSum(values: Iterable<DecInput>): Dec {
  let acc: Dec = ZERO;
  for (const v of values) acc = decAdd(acc, v);
  return acc;
}

/** Canonical plain decimal string without exponent or trailing zeros ("-0.0016", "80"). */
export function decToString(a: DecInput): string {
  const x = dec(a);
  const neg = x.c < 0n;
  const digits = (neg ? -x.c : x.c).toString();
  if (x.e === 0) return (neg ? '-' : '') + digits;
  const padded = digits.padStart(x.e + 1, '0');
  const cut = padded.length - x.e;
  return `${neg ? '-' : ''}${padded.slice(0, cut)}.${padded.slice(cut)}`;
}

/** Nearest IEEE-754 double of the exact value (single conversion, no accumulated drift). */
export function decToNumber(a: DecInput): number {
  return Number(decToString(a));
}
All files