src/history/decimal.ts
v0.3.0 · 4.2 KB
// 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 };
}