src/risk/decimal.ts
v0.3.0 · 5.8 KB
// 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));
}