Skip to content
markpaper

src/history/decimal.ts

v0.3.0 · 4.2 KB

Download file
// Private exact decimal arithmetic for the history module.
//
// Hyperliquid sends every size, price, PnL and fee as a decimal string. Summing
// them as floats drifts (0.1 + 0.2 !== 0.3), and a position that should be
// exactly flat ends up as 1e-17, which breaks flat -> flat cycle detection.
// Values are kept as `mantissa / 10^scale` with a BigInt mantissa.
//
// This is a deliberate private copy: modules of the kit must not import each
// other (only ../transport/types.js).

/** Exact decimal: value = m / 10^s, s >= 0. */
export interface Dec {
  readonly m: bigint;
  readonly s: number;
}

const DEC_RE = /^([+-])?(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
const MAX_EXPONENT = 400;

export const ZERO: Dec = { m: 0n, s: 0 };

const pow10 = (n: number): bigint => 10n ** BigInt(n);

/** Parses a decimal string or finite number; returns null for anything else. */
export function parseDec(v: unknown): Dec | null {
  let str: string;
  if (typeof v === 'number') {
    if (!Number.isFinite(v)) return null;
    str = String(v);
  } else if (typeof v === 'string') {
    str = v.trim();
  } else {
    return null;
  }
  const mt = DEC_RE.exec(str);
  if (!mt) return null;
  const sign = mt[1];
  const ip = mt[2] ?? '';
  const fp = mt[3] ?? '';
  if (ip === '' && fp === '') return null;
  const exp = mt[4] !== undefined ? Number(mt[4]) : 0;
  if (!Number.isSafeInteger(exp) || Math.abs(exp) > MAX_EXPONENT) return null;
  let m = BigInt(ip + fp || '0');
  let s = fp.length - exp;
  if (s < 0) {
    m *= pow10(-s);
    s = 0;
  }
  if (sign === '-') m = -m;
  return normalize({ m, s });
}

/** Like parseDec but throws a TypeError naming the field. */
export function requireDec(v: unknown, field: string): Dec {
  const d = parseDec(v);
  if (d === null) throw new TypeError(`history: ${field} is not a decimal: ${String(v)}`);
  return d;
}

/** Strips trailing zeros from the fractional part. */
function normalize(d: Dec): Dec {
  let { m, s } = d;
  while (s > 0 && m % 10n === 0n) {
    m /= 10n;
    s -= 1;
  }
  return { m, s };
}

function align(a: Dec, b: Dec): [bigint, bigint, number] {
  if (a.s === b.s) return [a.m, b.m, a.s];
  if (a.s > b.s) return [a.m, b.m * pow10(a.s - b.s), a.s];
  return [a.m * pow10(b.s - a.s), b.m, b.s];
}

export function add(a: Dec, b: Dec): Dec {
  const [x, y, s] = align(a, b);
  return normalize({ m: x + y, s });
}

export function sub(a: Dec, b: Dec): Dec {
  const [x, y, s] = align(a, b);
  return normalize({ m: x - y, s });
}

export function mul(a: Dec, b: Dec): Dec {
  return normalize({ m: a.m * b.m, s: a.s + b.s });
}

export function neg(a: Dec): Dec {
  return { m: -a.m, s: a.s };
}

export function abs(a: Dec): Dec {
  return a.m < 0n ? neg(a) : a;
}

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

export function sign(a: Dec): -1 | 0 | 1 {
  return a.m < 0n ? -1 : a.m > 0n ? 1 : 0;
}

export function max(a: Dec, b: Dec): Dec {
  return cmp(a, b) >= 0 ? a : b;
}

/**
 * a / b rounded half away from zero to `precision` fractional digits.
 * Throws on division by zero.
 */
export function div(a: Dec, b: Dec, precision = 12): Dec {
  if (b.m === 0n) throw new RangeError('history: division by zero');
  // a/b = (a.m * 10^b.s) / (b.m * 10^a.s); scale the numerator by 10^precision.
  let num = a.m * pow10(b.s + precision);
  let den = b.m * pow10(a.s);
  if (den < 0n) {
    num = -num;
    den = -den;
  }
  let q = num / den;
  const r = num % den;
  const r2 = (r < 0n ? -r : r) * 2n;
  if (r2 >= den) q += num < 0n ? -1n : 1n;
  return normalize({ m: q, s: precision });
}

/** Plain decimal string without exponent, e.g. "-12.5", "0". */
export function toStr(d: Dec): string {
  const n = normalize(d);
  const negative = n.m < 0n;
  const digits = (negative ? -n.m : n.m).toString();
  if (n.s === 0) return (negative ? '-' : '') + digits;
  const padded = digits.padStart(n.s + 1, '0');
  const ip = padded.slice(0, padded.length - n.s);
  const fp = padded.slice(padded.length - n.s);
  return `${negative ? '-' : ''}${ip}.${fp}`;
}

export function toNum(d: Dec): number {
  return Number(toStr(d));
}

/** 10^-k as a Dec (k = 6 gives 0.000001). */
export function pow10Neg(k: number): Dec {
  return k >= 0 ? { m: 1n, s: k } : { m: pow10(-k), s: 0 };
}
All files