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