|
| 1 | +import invariant from 'tiny-invariant'; |
| 2 | + |
| 3 | +export type Numberish = number | string | bigint; |
| 4 | +export type FixedPointNumberValue<T extends FixedPointNumber = FixedPointNumber> = Numberish | T; |
| 5 | + |
| 6 | +export enum RoundMode { |
| 7 | + ROUND_DOWN, |
| 8 | + ROUND_UP, |
| 9 | + ROUND_HALF_UP, |
| 10 | + ROUND_HALF_DOWN, |
| 11 | +} |
| 12 | + |
| 13 | +interface IFixedPointNumber { |
| 14 | + add(value: FixedPointNumberValue): IFixedPointNumber; |
| 15 | + sub(value: FixedPointNumberValue): IFixedPointNumber; |
| 16 | + mul(value: FixedPointNumberValue): IFixedPointNumber; |
| 17 | + div(value: FixedPointNumberValue): IFixedPointNumber; |
| 18 | + pow(exp: FixedPointNumberValue): IFixedPointNumber; |
| 19 | + scaleUp(scale: number): IFixedPointNumber; |
| 20 | + scaleDown(scale: number, rounding: RoundMode): IFixedPointNumber; |
| 21 | + format(decimals?: number): string; |
| 22 | +} |
| 23 | + |
| 24 | +const countDecimals = (n: number) => { |
| 25 | + if (Math.floor(n) === n) return 0; |
| 26 | + return n.toString().split('.')[1].length || 0; |
| 27 | +}; |
| 28 | + |
| 29 | +export class FixedPointNumber implements IFixedPointNumber { |
| 30 | + readonly value: bigint; |
| 31 | + readonly scale: bigint; |
| 32 | + |
| 33 | + constructor(value: FixedPointNumberValue, scale?: Numberish) { |
| 34 | + if (value instanceof FixedPointNumber) { |
| 35 | + this.value = value.value; |
| 36 | + this.scale = value.scale; |
| 37 | + } else { |
| 38 | + invariant( |
| 39 | + scale !== undefined, |
| 40 | + 'scale must be provided if initializing from other than a FixedPointNumber' |
| 41 | + ); |
| 42 | + this.value = BigInt(value); |
| 43 | + this.scale = BigInt(scale); |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + add(value: FixedPointNumberValue): FixedPointNumber { |
| 48 | + if (!(value instanceof FixedPointNumber)) { |
| 49 | + const wrappedValue = BigInt(value); |
| 50 | + return new FixedPointNumber(wrappedValue + this.value, this.scale); |
| 51 | + } |
| 52 | + if (this.scale === value.scale) { |
| 53 | + return new FixedPointNumber(this.value + value.value, this.scale); |
| 54 | + } |
| 55 | + if (this.scale > value.scale) { |
| 56 | + return new FixedPointNumber(this.value + value.scaleUp(this.scale).value, this.scale); |
| 57 | + } |
| 58 | + return new FixedPointNumber(this.scaleUp(value.scale).value + value.value, value.scale); |
| 59 | + } |
| 60 | + |
| 61 | + sub(value: FixedPointNumberValue): FixedPointNumber { |
| 62 | + if (!(value instanceof FixedPointNumber)) { |
| 63 | + const wrappedValue = BigInt(value); |
| 64 | + return new FixedPointNumber(this.value - wrappedValue, this.scale); |
| 65 | + } |
| 66 | + if (this.scale === value.scale) { |
| 67 | + return new FixedPointNumber(this.value - value.value, this.scale); |
| 68 | + } |
| 69 | + if (this.scale > value.scale) { |
| 70 | + return new FixedPointNumber(this.value - value.scaleUp(this.scale).value, this.scale); |
| 71 | + } |
| 72 | + return new FixedPointNumber(this.scaleUp(value.scale).value - value.value, value.scale); |
| 73 | + } |
| 74 | + |
| 75 | + mul(value: FixedPointNumberValue): FixedPointNumber { |
| 76 | + if (!(value instanceof FixedPointNumber)) { |
| 77 | + const wrappedValue = BigInt(value); |
| 78 | + return new FixedPointNumber(this.value * wrappedValue, this.scale * BigInt(2)); |
| 79 | + } |
| 80 | + return new FixedPointNumber(this.value * value.value, this.scale + value.scale); |
| 81 | + } |
| 82 | + |
| 83 | + scaleMul(value: Numberish): FixedPointNumber { |
| 84 | + const wrappedValue = BigInt(value); |
| 85 | + return new FixedPointNumber(this.value * wrappedValue, this.scale); |
| 86 | + } |
| 87 | + |
| 88 | + div(value: FixedPointNumberValue): FixedPointNumber { |
| 89 | + if (!(value instanceof FixedPointNumber)) { |
| 90 | + const wrappedValue = BigInt(value); |
| 91 | + return new FixedPointNumber(this.value / wrappedValue, 0); |
| 92 | + } |
| 93 | + const dividend = this.scale < value.scale ? this.scaleUp(value.scale) : this; |
| 94 | + const dividendWithMinPrecision = |
| 95 | + dividend.scale - value.scale < BigInt(3) ? this.scaleUp(dividend.scale + BigInt(3)) : this; |
| 96 | + return new FixedPointNumber( |
| 97 | + dividendWithMinPrecision.value / value.value, |
| 98 | + dividendWithMinPrecision.scale - value.scale |
| 99 | + ); |
| 100 | + } |
| 101 | + |
| 102 | + scaleDiv(value: Numberish): FixedPointNumber { |
| 103 | + const wrappedValue = BigInt(value); |
| 104 | + return new FixedPointNumber(this.value / wrappedValue, this.scale); |
| 105 | + } |
| 106 | + |
| 107 | + pow(exp: Numberish): FixedPointNumber { |
| 108 | + const wrappedExp = BigInt(exp); |
| 109 | + return new FixedPointNumber(this.value ** BigInt(exp), this.scale ** wrappedExp); |
| 110 | + } |
| 111 | + |
| 112 | + scaleit(scale: Numberish): FixedPointNumber { |
| 113 | + const wrappedScale = BigInt(scale); |
| 114 | + if (wrappedScale === this.scale) { |
| 115 | + return this; |
| 116 | + } |
| 117 | + if (wrappedScale > this.scale) { |
| 118 | + return this.scaleUp(scale); |
| 119 | + } |
| 120 | + return this.scaleDown(scale, RoundMode.ROUND_DOWN); |
| 121 | + } |
| 122 | + |
| 123 | + scaleUp(scale: Numberish): FixedPointNumber { |
| 124 | + const wrappedScale = BigInt(scale); |
| 125 | + invariant(wrappedScale >= this.scale, 'scale must be higher or equal'); |
| 126 | + return new FixedPointNumber( |
| 127 | + this.value * BigInt(10) ** BigInt(wrappedScale - this.scale), |
| 128 | + scale |
| 129 | + ); |
| 130 | + } |
| 131 | + |
| 132 | + scaleDown(scale: Numberish, rounding: RoundMode): FixedPointNumber { |
| 133 | + const wrappedScale = BigInt(scale); |
| 134 | + if (wrappedScale === this.scale) return this; |
| 135 | + // we leave 1 extra decimal to handle rounding |
| 136 | + const preNewValue = this.value / BigInt(10) ** BigInt(this.scale - wrappedScale - BigInt(1)); |
| 137 | + switch (rounding) { |
| 138 | + case RoundMode.ROUND_DOWN: |
| 139 | + return new FixedPointNumber(preNewValue / BigInt(10), wrappedScale); |
| 140 | + case RoundMode.ROUND_UP: |
| 141 | + return new FixedPointNumber(preNewValue / BigInt(10) + BigInt(1), wrappedScale); |
| 142 | + case RoundMode.ROUND_HALF_UP: |
| 143 | + return new FixedPointNumber((preNewValue + BigInt(5)) / BigInt(10), wrappedScale); |
| 144 | + case RoundMode.ROUND_HALF_DOWN: |
| 145 | + return new FixedPointNumber((preNewValue + BigInt(4)) / BigInt(10), wrappedScale); |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + eq(value: FixedPointNumberValue): boolean { |
| 150 | + if (value instanceof FixedPointNumber) { |
| 151 | + if (this.scale > value.scale) { |
| 152 | + return this.value === value.value * BigInt(10) * (this.scale - value.scale); |
| 153 | + } |
| 154 | + return this.value * BigInt(10) ** (value.scale - this.scale) === value.value; |
| 155 | + } |
| 156 | + return this.value === BigInt(value); |
| 157 | + } |
| 158 | + |
| 159 | + lte(value: FixedPointNumberValue): boolean { |
| 160 | + if (value instanceof FixedPointNumber) { |
| 161 | + if (this.scale > value.scale) { |
| 162 | + return this.value <= value.value * BigInt(10) ** (this.scale - value.scale); |
| 163 | + } |
| 164 | + return this.value * BigInt(10) ** (value.scale - this.scale) <= value.value; |
| 165 | + } |
| 166 | + return this.value <= BigInt(value); |
| 167 | + } |
| 168 | + |
| 169 | + gte(value: FixedPointNumberValue): boolean { |
| 170 | + if (value instanceof FixedPointNumber) { |
| 171 | + if (this.scale > value.scale) { |
| 172 | + return this.value >= value.value * BigInt(10) ** (this.scale - value.scale); |
| 173 | + } |
| 174 | + return this.value * BigInt(10) ** (value.scale - this.scale) >= value.value; |
| 175 | + } |
| 176 | + return this.value >= BigInt(value); |
| 177 | + } |
| 178 | + |
| 179 | + lt(value: FixedPointNumberValue): boolean { |
| 180 | + if (value instanceof FixedPointNumber) { |
| 181 | + if (this.scale > value.scale) { |
| 182 | + return this.value < value.value * BigInt(10) ** (this.scale - value.scale); |
| 183 | + } |
| 184 | + return this.value * BigInt(10) ** (value.scale - this.scale) < value.value; |
| 185 | + } |
| 186 | + return this.value < BigInt(value); |
| 187 | + } |
| 188 | + |
| 189 | + gt(value: FixedPointNumberValue): boolean { |
| 190 | + if (value instanceof FixedPointNumber) { |
| 191 | + if (this.scale > value.scale) { |
| 192 | + return this.value > value.value * BigInt(10) ** (this.scale - value.scale); |
| 193 | + } |
| 194 | + return this.value * BigInt(10) ** (value.scale - this.scale) > value.value; |
| 195 | + } |
| 196 | + return this.value > BigInt(value); |
| 197 | + } |
| 198 | + |
| 199 | + percent(percent: number): FixedPointNumber { |
| 200 | + const decimals = countDecimals(percent); |
| 201 | + const integerPercent = BigInt(percent * 10 ** decimals); |
| 202 | + const scaledPercent = integerPercent * BigInt(10) ** this.scale; |
| 203 | + const scaledDivisor = BigInt(10) ** (this.scale + BigInt(decimals)); |
| 204 | + return this.scaleMul(scaledPercent).scaleDiv(scaledDivisor); |
| 205 | + } |
| 206 | + |
| 207 | + format(decimals?: number): string { |
| 208 | + const stringValue = this.value.toString(); |
| 209 | + const positivePart = this.value < BigInt(0) ? stringValue.substring(1) : stringValue; |
| 210 | + const paddedPositivePart = positivePart.padStart(Number(this.scale) + 1, '0'); |
| 211 | + const positiveNumbers = paddedPositivePart.length - Number(this.scale); |
| 212 | + const positiveWithDecimals = |
| 213 | + paddedPositivePart.length === positiveNumbers |
| 214 | + ? paddedPositivePart.slice(0, positiveNumbers) |
| 215 | + : paddedPositivePart.slice(0, positiveNumbers) + |
| 216 | + '.' + |
| 217 | + paddedPositivePart.slice( |
| 218 | + positiveNumbers, |
| 219 | + decimals ? positiveNumbers + decimals : undefined |
| 220 | + ); |
| 221 | + if (this.value < BigInt(0)) { |
| 222 | + return '-' + positiveWithDecimals; |
| 223 | + } |
| 224 | + return positiveWithDecimals; |
| 225 | + } |
| 226 | + |
| 227 | + toNumber(): number { |
| 228 | + return Number(this.format()); |
| 229 | + } |
| 230 | +} |
0 commit comments