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