@hastom/fixed-point
Version:
Light lib for fixed point math made around native bigint
599 lines (524 loc) • 18.5 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { abs, max2, min2, pow10, toPrecision } from './math'
export enum Rounding {
/**
* Rounds away from zero
* Example: 1.5 -> 2, -1.5 -> -2
*/
ROUND_UP,
/**
* Rounds towards zero
* Example: 1.5 -> 1, -1.5 -> -1
*/
ROUND_DOWN,
/**
* Rounds towards Infinity
* Example: 1.5 -> 2, -1.5 -> -1
*/
ROUND_CEIL,
/**
* Rounds towards -Infinity
* Example: 1.5 -> 1, -1.5 -> -2
*/
ROUND_FLOOR,
/**
* Rounds towards nearest neighbour.
* If equidistant, rounds away from zero
* Example: 1.5 -> 2, -1.5 -> -2
*/
ROUND_HALF_UP,
/**
* Rounds towards nearest neighbour.
* If equidistant, rounds towards zero
* Example: 1.5 -> 1, -1.5 -> -1
*/
ROUND_HALF_DOWN,
/**
* Rounds towards nearest neighbour.
* If equidistant, rounds towards even neighbour
* Example: 1.5 -> 2, 2.5 -> 2
*/
ROUND_HALF_EVEN,
/**
* Rounds towards nearest neighbour.
* If equidistant, rounds towards Infinity
* Example: 1.5 -> 2, -1.5 -> -1
*/
ROUND_HALF_CEIL,
/**
* Rounds towards nearest neighbour.
* If equidistant, rounds towards -Infinity
* Example: 1.5 -> 1, -1.5 -> -2
*/
ROUND_HALF_FLOOR
}
export enum Decimals {
left = 'left',
right = 'right',
min = 'min',
max = 'max',
add = 'add',
sub = 'sub'
}
export type PrecisionResolution = Decimals | number | bigint
const pickPrecision = (
aPrecision: bigint,
bPrecision: bigint,
precisionResolution: PrecisionResolution,
): bigint => {
if (typeof precisionResolution !== 'string') {
return BigInt(precisionResolution)
}
switch (precisionResolution) {
case Decimals.left:
return aPrecision
case Decimals.right:
return bPrecision
case Decimals.min:
return min2(aPrecision, bPrecision)
case Decimals.max:
return max2(aPrecision, bPrecision)
case Decimals.add:
return aPrecision + bPrecision
case Decimals.sub:
return max2(aPrecision, bPrecision) - min2(aPrecision, bPrecision)
}
}
/**
* Integer division `numerator / denominator` rounded according to the given mode.
* The remainder is the discarded fractional part; rounding decides whether the
* truncated quotient is moved one step away from zero.
*/
const roundDiv = (
numerator: bigint,
denominator: bigint,
rounding: Rounding,
): bigint => {
if (denominator < 0n) {
numerator = -numerator
denominator = -denominator
}
const isNegative = numerator < 0n
const absNumerator = isNegative ? -numerator : numerator
const quotient = absNumerator / denominator
const remainder = absNumerator % denominator
if (remainder === 0n) {
return isNegative ? -quotient : quotient
}
const twiceRemainder = remainder * 2n
let roundUp = false
switch (rounding) {
case Rounding.ROUND_UP: // Away from zero
roundUp = true
break
case Rounding.ROUND_DOWN: // Towards zero
roundUp = false
break
case Rounding.ROUND_CEIL: // Towards Infinity
roundUp = !isNegative
break
case Rounding.ROUND_FLOOR: // Towards -Infinity
roundUp = isNegative
break
case Rounding.ROUND_HALF_UP: // If halfway, away from zero
roundUp = twiceRemainder >= denominator
break
case Rounding.ROUND_HALF_DOWN: // If halfway, towards zero
roundUp = twiceRemainder > denominator
break
case Rounding.ROUND_HALF_EVEN: // If halfway, towards even neighbour
roundUp = twiceRemainder > denominator || (twiceRemainder === denominator && quotient % 2n === 1n)
break
case Rounding.ROUND_HALF_CEIL: // If halfway, towards Infinity
roundUp = twiceRemainder > denominator || (twiceRemainder === denominator && !isNegative)
break
case Rounding.ROUND_HALF_FLOOR: // If halfway, towards -Infinity
roundUp = twiceRemainder > denominator || (twiceRemainder === denominator && isNegative)
break
}
const rounded = roundUp ? quotient + 1n : quotient
return isNegative ? -rounded : rounded
}
/**
* Like {@link toPrecision}, but when precision is reduced the discarded digits
* are rounded according to the given mode instead of always being truncated.
*/
const toPrecisionRounded = (
base: bigint,
to: bigint,
from: bigint,
rounding?: Rounding,
): bigint => {
if (to >= from) {
return toPrecision(base, to, from)
}
if (rounding === undefined || rounding === Rounding.ROUND_DOWN) {
return base / pow10(from - to)
}
return roundDiv(base, pow10(from - to), rounding)
}
export class FixedPoint {
static min(arg0: FixedPoint, ...args: FixedPoint[]): FixedPoint {
let min = arg0
for (const arg of args) {
if (arg.lt(min)) {
min = arg
}
}
return min
}
static max(arg0: FixedPoint, ...args: FixedPoint[]): FixedPoint {
let max = arg0
for (const arg of args) {
if (arg.gt(max)) {
max = arg
}
}
return max
}
base: bigint
precision: bigint
constructor(base: bigint, precision: bigint) {
this.base = base
this.precision = precision
}
add(arg: FixedPoint, resultPrecision?: PrecisionResolution): FixedPoint {
if (resultPrecision === undefined && this.precision === arg.precision) {
return new FixedPoint(this.base + arg.base, this.precision)
}
const aPrecision = this.precision
const bPrecision = arg.precision
const calcPrecision = max2(aPrecision, bPrecision)
const targetPrecision = pickPrecision(aPrecision, bPrecision, resultPrecision ?? Decimals.left)
const aBase = toPrecision(this.base, calcPrecision, aPrecision)
const bBase = toPrecision(arg.base, calcPrecision, bPrecision)
return new FixedPoint(toPrecision(aBase + bBase, targetPrecision, calcPrecision), targetPrecision)
}
sub(arg: FixedPoint, resultPrecision?: PrecisionResolution): FixedPoint {
if (resultPrecision === undefined && this.precision === arg.precision) {
return new FixedPoint(this.base - arg.base, this.precision)
}
const aPrecision = this.precision
const bPrecision = arg.precision
const calcPrecision = max2(aPrecision, bPrecision)
const targetPrecision = pickPrecision(aPrecision, bPrecision, resultPrecision ?? Decimals.left)
const aBase = toPrecision(this.base, calcPrecision, aPrecision)
const bBase = toPrecision(arg.base, calcPrecision, bPrecision)
return new FixedPoint(toPrecision(aBase - bBase, targetPrecision, calcPrecision), targetPrecision)
}
mul(
arg: FixedPoint,
resultPrecision?: PrecisionResolution,
rounding?: Rounding,
): FixedPoint {
// Hot path: kept tiny and byte-identical to the original so V8 keeps inlining it.
// Any rounding/precision request is delegated to the (cold) general path.
if (rounding === undefined && resultPrecision === undefined && this.precision === arg.precision) {
return new FixedPoint((this.base * arg.base) / pow10(this.precision), this.precision)
}
return this.mulGeneral(arg, resultPrecision, rounding)
}
private mulGeneral(
arg: FixedPoint,
resultPrecision: PrecisionResolution | undefined,
rounding: Rounding | undefined,
): FixedPoint {
const aPrecision = this.precision
const bPrecision = arg.precision
const calcPrecision = aPrecision + bPrecision
const targetPrecision = pickPrecision(aPrecision, bPrecision, resultPrecision ?? Decimals.max)
const rawBase = this.base * arg.base // at calcPrecision
return new FixedPoint(toPrecisionRounded(rawBase, targetPrecision, calcPrecision, rounding), targetPrecision)
}
div(
arg: FixedPoint,
resultPrecision?: PrecisionResolution,
rounding?: Rounding,
): FixedPoint {
// Hot path: kept tiny and byte-identical to the original so V8 keeps inlining it.
// Any rounding/precision request is delegated to the (cold) general path.
if (rounding === undefined && resultPrecision === undefined && this.precision === arg.precision) {
return new FixedPoint((this.base * pow10(this.precision)) / arg.base, this.precision)
}
return this.divGeneral(arg, resultPrecision, rounding)
}
private divGeneral(
arg: FixedPoint,
resultPrecision: PrecisionResolution | undefined,
rounding: Rounding | undefined,
): FixedPoint {
const aPrecision = this.precision
const bPrecision = arg.precision
const targetPrecision = pickPrecision(aPrecision, bPrecision, resultPrecision ?? Decimals.max)
// result at targetPrecision: this.base * 10^(bPrecision + targetPrecision - aPrecision) / arg.base
const shift = bPrecision + targetPrecision - aPrecision
let numerator: bigint
let denominator: bigint
if (shift >= 0n) {
numerator = this.base * pow10(shift)
denominator = arg.base
} else {
numerator = this.base
denominator = arg.base * pow10(-shift)
}
if (rounding === undefined || rounding === Rounding.ROUND_DOWN) {
return new FixedPoint(numerator / denominator, targetPrecision)
}
return new FixedPoint(roundDiv(numerator, denominator, rounding), targetPrecision)
}
cmp(arg: FixedPoint, comparator: (a: bigint, b: bigint) => boolean): boolean {
const aPrecision = this.precision
const bPrecision = arg.precision
const newPrecision = max2(aPrecision, bPrecision)
const aBase = toPrecision(this.base, newPrecision, aPrecision)
const bBase = toPrecision(arg.base, newPrecision, bPrecision)
return comparator(aBase, bBase)
}
eq(arg: FixedPoint): boolean {
if (this.precision === arg.precision) {return this.base === arg.base}
return this.cmp(arg, (a, b) => a === b)
}
gt(arg: FixedPoint): boolean {
if (this.precision === arg.precision) {return this.base > arg.base}
return this.cmp(arg, (a, b) => a > b)
}
lt(arg: FixedPoint): boolean {
if (this.precision === arg.precision) {return this.base < arg.base}
return this.cmp(arg, (a, b) => a < b)
}
gte(arg: FixedPoint): boolean {
if (this.precision === arg.precision) {return this.base >= arg.base}
return this.cmp(arg, (a, b) => a >= b)
}
lte(arg: FixedPoint): boolean {
if (this.precision === arg.precision) {return this.base <= arg.base}
return this.cmp(arg, (a, b) => a <= b)
}
neg(): FixedPoint {
return new FixedPoint(-this.base, this.precision)
}
abs(): FixedPoint {
return new FixedPoint(abs(this.base), this.precision)
}
sqrt(): FixedPoint {
if (this.isNegative()) {
throw new Error('Cannot calculate square root of negative number')
}
if (this.isZero()) {
return new FixedPoint(0n, this.precision)
}
// For Newton-Raphson method, we need higher precision for intermediate calculations
const workingPrecision = this.precision + 10n
const workingThis = new FixedPoint(toPrecision(this.base, workingPrecision, this.precision), workingPrecision)
// Initial guess: use the number shifted right by half the precision
// This gives us a reasonable starting point for the Newton-Raphson method
let x = new FixedPoint(workingThis.base >> (workingPrecision / 2n), workingPrecision)
// Handle case where initial guess is zero (for very small numbers)
if (x.isZero()) {
x = new FixedPoint(10n ** (workingPrecision / 2n), workingPrecision)
}
const two = new FixedPoint(2n * (10n ** workingPrecision), workingPrecision)
const epsilon = new FixedPoint(1n, workingPrecision) // Minimum precision unit
// Newton-Raphson iteration: x_{n+1} = (x_n + a/x_n) / 2
for (let i = 0; i < 50; i++) { // Maximum 50 iterations to prevent infinite loops
const quotient = workingThis.div(x, workingPrecision)
const newX = x.add(quotient, workingPrecision).div(two, workingPrecision)
// Check for convergence
if (newX.sub(x, workingPrecision).abs().lte(epsilon)) {
break
}
x = newX
}
// Convert back to original precision
return x.toPrecision(this.precision)
}
isZero(): boolean {
return this.base === 0n
}
isPositive(): boolean {
return this.base > 0n
}
isNegative(): boolean {
return this.base < 0n
}
floor() {
return this.round(Rounding.ROUND_FLOOR)
}
ceil() {
return this.round(Rounding.ROUND_CEIL)
}
round(mode: Rounding = Rounding.ROUND_HALF_UP): FixedPoint {
// No rounding needed for zero precision
if (this.precision === 0n) {
return new FixedPoint(this.base, this.precision)
}
const isNegative = this.isNegative()
const absBase = abs(this.base)
const divisor = pow10(this.precision)
const integerPart = absBase / divisor
const fractionalPart = absBase % divisor
const isHalfwayCase = fractionalPart * 2n === divisor
let rounded = integerPart
switch (mode) {
case Rounding.ROUND_UP: // Away from zero
// Round up if there's any fractional part
if (fractionalPart > 0n) {
rounded = integerPart + 1n
}
break
case Rounding.ROUND_DOWN: // Towards zero
// Keep the integer part (truncate)
rounded = integerPart
break
case Rounding.ROUND_CEIL: // Towards Infinity
if (fractionalPart > 0n) {
if (!isNegative) {
rounded = integerPart + 1n
} else {
rounded = integerPart
}
}
break
case Rounding.ROUND_FLOOR: // Towards -Infinity
if (fractionalPart > 0n) {
if (!isNegative) {
rounded = integerPart
} else {
rounded = integerPart + 1n
}
}
break
case Rounding.ROUND_HALF_UP: // If halfway, away from zero
if (fractionalPart > divisor / 2n || (isHalfwayCase)) {
rounded = integerPart + 1n
}
break
case Rounding.ROUND_HALF_DOWN: // If halfway, towards zero
if (fractionalPart > divisor / 2n) {
rounded = integerPart + 1n
}
break
case Rounding.ROUND_HALF_EVEN: // If halfway, towards even neighbor
if (fractionalPart > divisor / 2n) {
rounded = integerPart + 1n
} else if (isHalfwayCase) {
// If integerPart is even, keep it; if odd, round up
if (integerPart % 2n === 1n) {
rounded = integerPart + 1n
}
}
break
case Rounding.ROUND_HALF_CEIL: // If halfway, towards Infinity
if (fractionalPart > divisor / 2n) {
rounded = integerPart + 1n
} else if (isHalfwayCase) {
if (!isNegative) {
rounded = integerPart + 1n
}
}
break
case Rounding.ROUND_HALF_FLOOR: // If halfway, towards -Infinity
if (fractionalPart > divisor / 2n) {
rounded = integerPart + 1n
} else if (isHalfwayCase) {
if (isNegative) {
rounded = integerPart + 1n
}
}
break
}
// Apply sign and create new FixedPoint instance with the same precision
const roundedBase = isNegative ? -rounded * divisor : rounded * divisor
return new FixedPoint(roundedBase, this.precision)
}
setPrecision(newPrecision: bigint, rounding: Rounding = Rounding.ROUND_DOWN): void {
if (newPrecision < this.precision) {
if (rounding === Rounding.ROUND_DOWN) {
// Fast path: ROUND_DOWN is just truncation — BigInt division truncates towards zero
this.base = this.base / pow10(this.precision - newPrecision)
this.precision = newPrecision
} else {
const rounded = new FixedPoint(this.base, this.precision - newPrecision).round(rounding)
this.base = toPrecision(rounded.base, newPrecision, this.precision)
this.precision = newPrecision
}
} else if (newPrecision > this.precision) {
this.base = toPrecision(this.base, newPrecision, this.precision)
this.precision = newPrecision
}
}
toPrecision(resultPrecision: number | bigint, rounding: Rounding = Rounding.ROUND_DOWN): FixedPoint {
const newPrecision = BigInt(resultPrecision)
if (newPrecision < this.precision) {
if (rounding === Rounding.ROUND_DOWN) {
return new FixedPoint(this.base / pow10(this.precision - newPrecision), newPrecision)
}
const rounded = new FixedPoint(this.base, this.precision - newPrecision).round(rounding)
return new FixedPoint(toPrecision(rounded.base, newPrecision, this.precision), newPrecision)
} else {
return new FixedPoint(toPrecision(this.base, newPrecision, this.precision), newPrecision)
}
}
toString() {
return this.base.toString()
}
toJSON() {
return this.toString()
}
toDecimalString(trimTrailingZeros = false) {
const isNegative = this.isNegative()
let str = abs(this.base).toString().padStart(Number(this.precision) + 1, '0')
if (isNegative) {
str = `-${str}`
}
if (this.precision === 0n) {
return str
}
const intPart = str.slice(0, -Number(this.precision))
const fracPart = str.slice(-Number(this.precision))
if (!trimTrailingZeros) {
return `${intPart}.${fracPart}`
}
let end = fracPart.length
while (end > 0 && fracPart.charCodeAt(end - 1) === 48) {
end -= 1
}
return end === 0 ? intPart : `${intPart}.${fracPart.slice(0, end)}`
}
toDecimal() {
return Number(this.toDecimalString())
}
valueOf() {
return this.toDecimal()
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface,@typescript-eslint/consistent-type-definitions,no-redeclare
export interface FixedPoint {
plus: FixedPoint['add'],
minus: FixedPoint['sub'],
times: FixedPoint['mul'],
multipliedBy: FixedPoint['mul'],
dividedBy: FixedPoint['div'],
isEqualTo: FixedPoint['eq'],
isGreaterThan: FixedPoint['gt'],
isLessThan: FixedPoint['lt'],
isGreaterThanOrEqualTo: FixedPoint['gte'],
isLessThanOrEqualTo: FixedPoint['lte'],
negated: FixedPoint['neg'],
absoluteValue: FixedPoint['abs'],
squareRoot: FixedPoint['sqrt'],
}
const proto = FixedPoint.prototype
proto.plus = proto.add
proto.minus = proto.sub
proto.times = proto.mul
proto.multipliedBy = proto.mul
proto.dividedBy = proto.div
proto.isEqualTo = proto.eq
proto.isGreaterThan = proto.gt
proto.isLessThan = proto.lt
proto.isGreaterThanOrEqualTo = proto.gte
proto.isLessThanOrEqualTo = proto.lte
proto.negated = proto.neg
proto.absoluteValue = proto.abs
proto.squareRoot = proto.sqrt