SPB Git

Repository Public

decimal.ts 6.8 KB 151 lines typescript

1/**2 * Exact decimal arithmetic for the derivatives module (contract: docs/v4/DERIVATIVES-ARCHITECTURE.md, "decimal.ts").3 *4 * A `Dec` is a bigint mantissa `m` and a non-negative integer scale `s`: value = m / 10^s. Addition, subtraction, multiplication5 * and comparison are exact. Division never rounds silently: the caller gives the number of decimals and the rounding mode.6 * No `number` takes part in any computation here; `toNumber` exists for display only.7 *8 * Rounding modes (same meaning in `div` and `quantize`):9 *   'down'      towards zero (truncate)10 *   'up'        away from zero11 *   'half_even' to nearest, ties to the even last digit (banker's rounding)12 */13export type DecStr = string;14export interface Dec { readonly m: bigint; readonly s: number }15export type RoundingMode = 'down' | 'up' | 'half_even';1617/** canonical decimal string: optional minus, no leading zeros, optional fraction, no exponent, no '+' */18export const DEC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/;19/** maximum number of digits (integer + fraction) accepted when parsing a string */20export const MAX_DIGITS = 60;21/** maximum scale accepted by `div` */22export const MAX_SCALE = 60;2324const POW10: bigint[] = [1n];25function pow10(n: number): bigint {26  for (let i = POW10.length; i <= n; i++) POW10.push(POW10[i - 1]! * 10n);27  return POW10[n]!;28}2930const ZERO: Dec = Object.freeze({ m: 0n, s: 0 });31const ONE: Dec = Object.freeze({ m: 1n, s: 0 });3233/** normalised constructor: strips trailing zeros of the fraction; zero is always {0n, 0} */34function mk(m: bigint, s: number): Dec {35  if (m === 0n) return ZERO;36  while (s > 0 && m % 10n === 0n) { m /= 10n; s -= 1; }37  return Object.freeze({ m, s });38}3940function check(x: Dec): Dec {41  if (typeof x !== 'object' || x === null || typeof x.m !== 'bigint' || !Number.isInteger(x.s) || x.s < 0) throw new TypeError('invalid Dec');42  return x;43}4445function parse(x: string): Dec {46  if (x.length > MAX_DIGITS + 2 || !DEC_RE.test(x)) throw new RangeError(`invalid decimal string: ${JSON.stringify(x.length > 80 ? `${x.slice(0, 80)}…` : x)}`);47  const neg = x.startsWith('-');48  const body = neg ? x.slice(1) : x;49  const dot = body.indexOf('.');50  const int = dot < 0 ? body : body.slice(0, dot);51  const frac = dot < 0 ? '' : body.slice(dot + 1);52  if (int.length + frac.length > MAX_DIGITS) throw new RangeError(`decimal string has more than ${MAX_DIGITS} digits`);53  const m = BigInt(int + frac);54  return mk(neg ? -m : m, frac.length);55}5657function from(x: DecStr | bigint | Dec): Dec {58  if (typeof x === 'string') return parse(x);59  if (typeof x === 'bigint') return mk(x, 0);60  if (typeof x === 'object' && x !== null) { check(x); return mk(x.m, x.s); }61  throw new TypeError(`cannot build a Dec from a ${typeof x}; use a decimal string or a bigint`);62}6364function str(x: Dec): DecStr {65  const n = mk(check(x).m, x.s);66  const neg = n.m < 0n;67  let digits = (neg ? -n.m : n.m).toString();68  if (n.s > 0) {69    if (digits.length <= n.s) digits = '0'.repeat(n.s - digits.length + 1) + digits;70    digits = `${digits.slice(0, digits.length - n.s)}.${digits.slice(digits.length - n.s)}`;71  }72  return neg ? `-${digits}` : digits;73}7475/** both mantissas at the common (larger) scale */76function align(a: Dec, b: Dec): [bigint, bigint, number] {77  check(a); check(b);78  if (a.s === b.s) return [a.m, b.m, a.s];79  return a.s > b.s ? [a.m, b.m * pow10(a.s - b.s), a.s] : [a.m * pow10(b.s - a.s), b.m, b.s];80}8182function add(a: Dec, b: Dec): Dec { const [x, y, s] = align(a, b); return mk(x + y, s); }83function sub(a: Dec, b: Dec): Dec { const [x, y, s] = align(a, b); return mk(x - y, s); }84function mul(a: Dec, b: Dec): Dec { check(a); check(b); return mk(a.m * b.m, a.s + b.s); }85function cmp(a: Dec, b: Dec): -1 | 0 | 1 { const [x, y] = align(a, b); return x < y ? -1 : x > y ? 1 : 0; }86function neg(a: Dec): Dec { return mk(-check(a).m, a.s); }87function abs(a: Dec): Dec { return mk(check(a).m < 0n ? -a.m : a.m, a.s); }88function isZero(a: Dec): boolean { return check(a).m === 0n; }8990/** integer quotient num/den rounded with `mode`; den must not be zero */91function roundedQuotient(num: bigint, den: bigint, mode: RoundingMode): bigint {92  if (den < 0n) { num = -num; den = -den; }93  let q = num / den;                 // truncates towards zero94  const r = num % den;               // same sign as num95  if (r === 0n) return q;96  const away = num < 0n ? -1n : 1n;97  if (mode === 'up') q += away;98  else if (mode === 'half_even') {99    const twice = (r < 0n ? -r : r) * 2n;100    if (twice > den || (twice === den && q % 2n !== 0n)) q += away;101  }102  return q;103}104105function div(a: Dec, b: Dec, scale: number, mode: RoundingMode): Dec {106  check(a); check(b);107  if (b.m === 0n) throw new RangeError('division by zero');108  if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) throw new RangeError(`division scale must be an integer in 0..${MAX_SCALE}`);109  if (mode !== 'down' && mode !== 'up' && mode !== 'half_even') throw new RangeError(`unknown rounding mode: ${String(mode)}`);110  // a/b · 10^scale = a.m · 10^(b.s + scale) / (b.m · 10^a.s)111  return mk(roundedQuotient(a.m * pow10(b.s + scale), b.m * pow10(a.s), mode), scale);112}113114function quantize(a: Dec, step: Dec, mode: 'down' | 'up'): Dec {115  const [x, st, s] = align(a, step);116  if (st <= 0n) throw new RangeError('quantize step must be positive');117  if (mode !== 'down' && mode !== 'up') throw new RangeError(`unknown quantize mode: ${String(mode)}`);118  return mk(roundedQuotient(x, st, mode) * st, s);119}120121function toNumber(a: Dec): number { return Number(str(a)); }122123export const D = {124  from, str, add, sub, mul, div, cmp, neg, abs, isZero, quantize,125  /** display only: never feed the result back into a calculation */126  toNumber,127  // additions to the contract (pure conveniences built on the members above)128  zero: ZERO,129  one: ONE,130  eq(a: Dec, b: Dec): boolean { return cmp(a, b) === 0; },131  lt(a: Dec, b: Dec): boolean { return cmp(a, b) < 0; },132  lte(a: Dec, b: Dec): boolean { return cmp(a, b) <= 0; },133  gt(a: Dec, b: Dec): boolean { return cmp(a, b) > 0; },134  gte(a: Dec, b: Dec): boolean { return cmp(a, b) >= 0; },135  min(a: Dec, b: Dec): Dec { return cmp(a, b) <= 0 ? a : b; },136  max(a: Dec, b: Dec): Dec { return cmp(a, b) >= 0 ? a : b; },137  sign(a: Dec): -1 | 0 | 1 { return check(a).m < 0n ? -1 : a.m > 0n ? 1 : 0; },138  isNeg(a: Dec): boolean { return check(a).m < 0n; },139  isPos(a: Dec): boolean { return check(a).m > 0n; },140  /** true when `a` is an exact multiple of the positive `step` */141  isMultiple(a: Dec, step: Dec): boolean {142    const [x, st] = align(a, step);143    if (st <= 0n) throw new RangeError('step must be positive');144    return x % st === 0n;145  },146  /** true when the value is a canonical decimal string within the digit limit (never throws) */147  isValid(x: unknown): x is DecStr {148    return typeof x === 'string' && x.length <= MAX_DIGITS + 2 && DEC_RE.test(x) && x.replace(/[-.]/g, '').length <= MAX_DIGITS;149  },150};151
Files main
Menu

Theme