Skip to content
markpaper

src/account/decimal.ts

v0.3.0 · 4.1 KB

Download file
// Private exact decimal arithmetic for the account module.
//
// Hyperliquid returns every balance, margin and size as a decimal string. Summing them as
// floats accumulates binary rounding error (0.1 + 0.2 !== 0.3), and equity is compared against
// stored peaks and thresholds, so the module keeps values as scaled BigInts and converts to
// `number` only at the output boundary. Division (ratios, ROE) is done in floating point on the
// already-exact operands: those are derived metrics, never re-sent to the exchange.

/** A decimal value `units / 10^scale`. */
export interface Dec {
  readonly units: bigint;
  readonly scale: number;
}

/**
 * Strict decimal grammar: optional minus, digits, optional fraction. No whitespace, exponent,
 * plus sign or empty parts. `" 100"` and `"abc"` are rejected — the knowledge base treats them as
 * a broken read, never as a number.
 */
const STRICT_DECIMAL = /^-?\d+(\.\d+)?$/;

export const ZERO: Dec = { units: 0n, scale: 0 };

/** Parses a strict decimal string; returns `null` for anything else (including non-strings). */
export function parseDec(input: unknown): Dec | null {
  if (typeof input !== 'string' || !STRICT_DECIMAL.test(input)) return null;
  const negative = input.startsWith('-');
  const body = negative ? input.slice(1) : input;
  const dot = body.indexOf('.');
  const intPart = dot === -1 ? body : body.slice(0, dot);
  const fracPart = dot === -1 ? '' : body.slice(dot + 1);
  const units = BigInt(intPart + fracPart);
  return { units: negative ? -units : units, scale: fracPart.length };
}

/** Converts a finite JS number to a decimal with at most `maxScale` fraction digits. */
export function decFromNumber(value: number, maxScale = 8): Dec | null {
  if (!Number.isFinite(value)) return null;
  const fixed = value.toFixed(maxScale);
  return parseDec(fixed);
}

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

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

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

export function neg(a: Dec): Dec {
  return { units: -a.units, scale: a.scale };
}

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

/** -1, 0 or 1. */
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.units < 0n ? -1 : a.units > 0n ? 1 : 0;
}

export function isZero(a: Dec): boolean {
  return a.units === 0n;
}

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

export function sum(values: readonly Dec[]): Dec {
  let acc = ZERO;
  for (const v of values) acc = add(acc, v);
  return acc;
}

/** Multiplies by `10^k` exactly (k >= 0). Used for rate -> basis points. */
export function shiftLeft(a: Dec, k: number): Dec {
  if (!Number.isInteger(k) || k < 0) throw new RangeError(`shiftLeft: k must be a non-negative integer, got ${k}`);
  if (k <= a.scale) return { units: a.units, scale: a.scale - k };
  return { units: a.units * 10n ** BigInt(k - a.scale), scale: 0 };
}

/** Canonical decimal string: no trailing fraction zeros, no `-0`. */
export function toDecString(a: Dec): string {
  const negative = a.units < 0n;
  let digits = (negative ? -a.units : a.units).toString();
  if (a.scale > 0) {
    digits = digits.padStart(a.scale + 1, '0');
    const intPart = digits.slice(0, digits.length - a.scale);
    const fracPart = digits.slice(digits.length - a.scale).replace(/0+$/, '');
    digits = fracPart ? `${intPart}.${fracPart}` : intPart;
  }
  return negative && digits !== '0' ? `-${digits}` : digits;
}

/** Nearest JS number to the exact decimal (parsed from the canonical string, not accumulated). */
export function toNumber(a: Dec): number {
  return Number(toDecString(a));
}
All files