Skip to content
markpaper

src/errors/decimal.ts

v0.3.0 · 3 KB

Download file
// Private exact decimal helpers for the errors module (a copy on purpose: modules do not import
// each other). Fill sizes and prices arrive as decimal strings; comparing them as floats can call
// a fill that equals the requested size an "overfill" (or the other way round), so comparisons are
// done on scaled BigInts.

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

/** Plain non-negative decimal as Hyperliquid prints it: `"0.5"`, `"100"`. No sign, exponent or blanks. */
export const PLAIN_UNSIGNED_DECIMAL = /^\d+(\.\d+)?$/;

const NUMBER_TEXT = /^(-)?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i;

/** Largest exponent accepted when expanding `1e-7`-style text; anything larger is not a size or price. */
const MAX_EXPONENT = 400;

/**
 * Parses a decimal from a strict plain string (`"-1.25"`, `"3"`) or a finite JS number (including
 * the exponent form `String(1e-7)` produces). Returns `null` for everything else: `"Infinity"`,
 * `"NaN"`, `""`, `" 1"`, `"1e3"` as a string, non-finite numbers.
 */
export function parseDecimal(input: unknown): Dec | null {
  let text: string;
  if (typeof input === 'number') {
    if (!Number.isFinite(input)) return null;
    text = String(input);
  } else if (typeof input === 'string') {
    if (!/^-?\d+(\.\d+)?$/.test(input)) return null;
    text = input;
  } else {
    return null;
  }
  const m = NUMBER_TEXT.exec(text);
  if (!m) return null;
  const intPart = m[2] ?? '';
  const fracPart = m[3] ?? '';
  if (intPart === '' && fracPart === '') return null;
  const exp = m[4] === undefined ? 0 : Number(m[4]);
  if (!Number.isInteger(exp) || Math.abs(exp) > MAX_EXPONENT) return null;
  let units = BigInt(`${intPart}${fracPart}` || '0');
  let scale = fracPart.length - exp;
  if (scale < 0) {
    units *= 10n ** BigInt(-scale);
    scale = 0;
  }
  return { units: m[1] ? -units : units, scale };
}

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

/** Exact sum. */
export function addDecimal(a: Dec, b: Dec): Dec {
  const [x, y] = align(a, b);
  return { units: x + y, scale: Math.max(a.scale, b.scale) };
}

/** Exact comparison: -1, 0 or 1. */
export function compareDecimal(a: Dec, b: Dec): -1 | 0 | 1 {
  const [x, y] = align(a, b);
  return x < y ? -1 : x > y ? 1 : 0;
}

/** Renders a decimal without exponent and without trailing fraction zeros. */
export function toPlainString(d: Dec): string {
  const negative = d.units < 0n;
  const digits = (negative ? -d.units : d.units).toString();
  if (d.scale === 0) return `${negative ? '-' : ''}${digits}`;
  const padded = digits.padStart(d.scale + 1, '0');
  const intPart = padded.slice(0, padded.length - d.scale);
  const fracPart = padded.slice(padded.length - d.scale).replace(/0+$/, '');
  const body = fracPart ? `${intPart}.${fracPart}` : intPart;
  return negative && body !== '0' ? `-${body}` : body;
}
All files