@rallycash/numbers 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { type DecimalLike, isDecimalLike } from './decimal.js'
2
+ import { expToString } from './number-utils.js'
3
+ import { formatUnits, parseUnits } from './units.js'
4
+
5
+ export type BigIntInput = bigint | number | string | DecimalLike
6
+
7
+ /**
8
+ * Float multipliers are turned into a rational at this scale.
9
+ * Server wallet code used 18. Web used 10. Pass `scale` to pick one.
10
+ * This is not a USD decimal count.
11
+ */
12
+ const DEFAULT_FLOAT_SCALE = 18
13
+
14
+ const toBigInt = (value: BigIntInput, decimals = 0): bigint => {
15
+ if (typeof value === 'bigint') return value
16
+ if (isDecimalLike(value)) return parseUnits(value.toFixed(decimals), decimals)
17
+ return parseUnits(expToString(value), decimals)
18
+ }
19
+
20
+ const toNumber = (value: BigIntInput, decimals = 0): number =>
21
+ Number(formatUnits(toBigInt(value), decimals))
22
+
23
+ const multiplyByNumber = (
24
+ value: bigint,
25
+ multiplier: number,
26
+ scale = DEFAULT_FLOAT_SCALE,
27
+ ) => {
28
+ if (!Number.isFinite(multiplier) || multiplier === 0) return 0n
29
+ const factor = 10 ** scale
30
+ const numerator =
31
+ multiplier < 1 ? 1n * BigInt(factor) : toBigInt(multiplier * factor)
32
+ const denominator =
33
+ multiplier < 1 ? toBigInt((1 / multiplier) * factor) : 1n * BigInt(factor)
34
+ return (value * numerator) / denominator
35
+ }
36
+
37
+ const createPercentage = (
38
+ numerator: BigIntInput,
39
+ denominator: BigIntInput,
40
+ scale = DEFAULT_FLOAT_SCALE,
41
+ ) => {
42
+ if (typeof numerator === 'number' && typeof denominator === 'number') {
43
+ return numerator / denominator
44
+ }
45
+ return Number(
46
+ formatUnits(
47
+ (toBigInt(numerator) * BigInt(10 ** scale)) / toBigInt(denominator),
48
+ scale,
49
+ ),
50
+ )
51
+ }
52
+
53
+ const abs = (value: bigint) => (value < 0n ? -value : value)
54
+
55
+ const min = (a: bigint, b: bigint) => (a < b ? a : b)
56
+ const max = (a: bigint, b: bigint) => (a > b ? a : b)
57
+
58
+ const multiplyAsBigInt = (a: BigIntInput, b: BigIntInput, decimals: number) =>
59
+ toNumber(toBigInt(a, decimals) * toBigInt(b, decimals), decimals * 2)
60
+
61
+ /**
62
+ * USD notional to token atomic units at `tokenPriceUsd`.
63
+ * `usdDecimals` is the caller's USD scale. This package does not pick one.
64
+ */
65
+ const usdToTokenAmount = (
66
+ usd: number,
67
+ tokenPriceUsd: number,
68
+ tokenDecimals: number,
69
+ usdDecimals: number,
70
+ ): bigint => {
71
+ if (
72
+ !(usd > 0) ||
73
+ !(tokenPriceUsd > 0) ||
74
+ tokenDecimals < 0 ||
75
+ usdDecimals < 0
76
+ )
77
+ return 0n
78
+ return (
79
+ (toBigInt(usd, usdDecimals) * 10n ** BigInt(tokenDecimals)) /
80
+ toBigInt(tokenPriceUsd, usdDecimals)
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Split a bigint amount into `count` parts as evenly as possible.
86
+ * Any remainder is distributed starting from the last bucket, so that
87
+ * `buckets[count - 1]` absorbs the remainder bits.
88
+ *
89
+ * Guarantees:
90
+ * - buckets.length === count
91
+ * - buckets.reduce(+, 0n) === amount
92
+ * - every bucket >= 0n
93
+ *
94
+ * Throws when count <= 0.
95
+ */
96
+ const splitEvenly = (amount: bigint, count: number): bigint[] => {
97
+ if (count <= 0) throw new Error('splitEvenly: count must be > 0')
98
+ if (count === 1) return [amount]
99
+ const base = amount / BigInt(count)
100
+ const remainder = amount - base * BigInt(count)
101
+ const buckets = Array.from({ length: count }, () => base)
102
+ const last = buckets[count - 1]
103
+ if (last === undefined) throw new Error('splitEvenly: missing bucket')
104
+ buckets[count - 1] = last + remainder
105
+ return buckets
106
+ }
107
+
108
+ /**
109
+ * Like `splitEvenly`, but each of the first N-1 buckets is jittered within
110
+ * ±`variance` (as a fraction of the even-split base, e.g. 0.2 = ±20%),
111
+ * and the last bucket absorbs whatever's left so the total is preserved
112
+ * exactly.
113
+ *
114
+ * - Returns `splitEvenly` when `variance <= 0`, `count === 1`, or `amount === 0n`.
115
+ * - The last bucket's variance is emergent (it absorbs the complement of
116
+ * the first N-1 jitters). If those buckets already sum past `amount`,
117
+ * the excess is taken back from them so the total stays exact and no
118
+ * bucket goes negative. Callers that need a strict per-bucket cap should
119
+ * compose with `clampToCaps`.
120
+ *
121
+ * Guarantees (same as splitEvenly):
122
+ * - buckets.length === count
123
+ * - buckets.reduce(+, 0n) === amount
124
+ * - every bucket >= 0n (when variance ≤ 1; throws otherwise on negative)
125
+ */
126
+ const splitWithVariance = (
127
+ amount: bigint,
128
+ count: number,
129
+ variance: number,
130
+ rng: () => number = Math.random,
131
+ ): bigint[] => {
132
+ if (count <= 0) throw new Error('splitWithVariance: count must be > 0')
133
+ if (count === 1 || variance <= 0 || amount === 0n) {
134
+ return splitEvenly(amount, count)
135
+ }
136
+ const clampedVariance = Math.min(1, Math.max(0, variance))
137
+
138
+ const precision = 1_000_000n
139
+ const base = amount / BigInt(count)
140
+
141
+ const buckets: bigint[] = []
142
+ let assigned = 0n
143
+ for (let i = 0; i < count - 1; i++) {
144
+ const factor = 1 + (rng() * 2 - 1) * clampedVariance
145
+ const scaled = BigInt(Math.round(factor * Number(precision)))
146
+ const bucket = (base * scaled) / precision
147
+ const nonNegative = bucket < 0n ? 0n : bucket
148
+ buckets.push(nonNegative)
149
+ assigned += nonNegative
150
+ }
151
+
152
+ let remaining = amount - assigned
153
+ if (remaining < 0n) {
154
+ for (let i = buckets.length - 1; i >= 0 && remaining < 0n; i--) {
155
+ const bucket = buckets[i] ?? 0n
156
+ const deficit = -remaining
157
+ const take = bucket < deficit ? bucket : deficit
158
+ buckets[i] = bucket - take
159
+ remaining += take
160
+ }
161
+ }
162
+ buckets.push(remaining < 0n ? 0n : remaining)
163
+ return buckets
164
+ }
165
+
166
+ /**
167
+ * Given an initial per-bucket allocation and per-bucket caps, clamp each
168
+ * bucket to its cap and redistribute the overflow to buckets that still
169
+ * have headroom (largest-headroom-first), preserving the total sum.
170
+ *
171
+ * Throws if `sum(caps) < sum(initial)`.
172
+ */
173
+ const clampToCaps = (initial: bigint[], caps: bigint[]): bigint[] => {
174
+ if (initial.length !== caps.length) {
175
+ throw new Error('clampToCaps: initial and caps must be the same length')
176
+ }
177
+ const total = initial.reduce((sum, amount) => sum + amount, 0n)
178
+ const totalCap = caps.reduce((sum, cap) => sum + cap, 0n)
179
+ if (total > totalCap) {
180
+ throw new Error('clampToCaps: total exceeds aggregate capacity')
181
+ }
182
+
183
+ const result = initial.map((amount, i) => {
184
+ const cap = caps[i] ?? 0n
185
+ return amount > cap ? cap : amount
186
+ })
187
+ let shortfall = total - result.reduce((sum, amount) => sum + amount, 0n)
188
+ if (shortfall === 0n) return result
189
+
190
+ const byHeadroomDesc = result
191
+ .map((_, i) => i)
192
+ .sort((a, b) => {
193
+ const headroomA = (caps[a] ?? 0n) - (result[a] ?? 0n)
194
+ const headroomB = (caps[b] ?? 0n) - (result[b] ?? 0n)
195
+ return headroomA > headroomB ? -1 : headroomA < headroomB ? 1 : 0
196
+ })
197
+
198
+ for (const i of byHeadroomDesc) {
199
+ if (shortfall === 0n) break
200
+ const headroom = (caps[i] ?? 0n) - (result[i] ?? 0n)
201
+ const give = shortfall < headroom ? shortfall : headroom
202
+ result[i] = (result[i] ?? 0n) + give
203
+ shortfall -= give
204
+ }
205
+
206
+ return result
207
+ }
208
+
209
+ export const BigIntUtils = {
210
+ multiplyByNumber,
211
+ createPercentage,
212
+ abs,
213
+ min,
214
+ max,
215
+ toNumber,
216
+ toBigInt,
217
+ multiplyAsBigInt,
218
+ usdToTokenAmount,
219
+ splitEvenly,
220
+ splitWithVariance,
221
+ clampToCaps,
222
+ }
package/src/decimal.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Structural stand-in for Prisma's `Decimal` and decimal.js.
3
+ * Both satisfy this: `toFixed` keeps the decimal string, `toNumber` is the float view.
4
+ */
5
+ export type DecimalLike = {
6
+ toFixed(digits: number): string
7
+ toNumber(): number
8
+ }
9
+
10
+ export type NumberInput = number | DecimalLike | null | undefined
11
+
12
+ export function isDecimalLike(value: unknown): value is DecimalLike {
13
+ if (typeof value !== 'object' || value === null) return false
14
+ if (!('toFixed' in value) || !('toNumber' in value)) return false
15
+ return (
16
+ typeof value.toFixed === 'function' && typeof value.toNumber === 'function'
17
+ )
18
+ }
19
+
20
+ export function coerceNumber(value: NumberInput): number | null | undefined {
21
+ if (value == null) return value
22
+ if (typeof value === 'number') return value
23
+ if (isDecimalLike(value)) return value.toNumber()
24
+ return undefined
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { type BigIntInput, BigIntUtils } from './bigint-utils.js'
2
+ export type { DecimalLike, NumberInput } from './decimal.js'
3
+ export {
4
+ type FormatNumberOptions,
5
+ type FormatUsdBalanceOptions,
6
+ type FormatUsdOptions,
7
+ NumberUtils,
8
+ type SignFormatOptions,
9
+ type UsdInput,
10
+ } from './number-utils.js'
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { NumberUtils } from './number-utils.js'
4
+
5
+ const decimalLike = {
6
+ toFixed(digits: number) {
7
+ return (1.5).toFixed(digits)
8
+ },
9
+ toNumber() {
10
+ return 1.5
11
+ },
12
+ }
13
+
14
+ describe('NumberUtils.formatNumber', () => {
15
+ it('formats a normal amount with grouping and two fraction digits', () => {
16
+ expect(NumberUtils.formatNumber(1234.5)).toBe('1,234.50')
17
+ })
18
+
19
+ it('abbreviates from 10 million', () => {
20
+ expect(NumberUtils.formatNumber(12_345_678)).toBe('12.3M')
21
+ expect(NumberUtils.formatNumber(1_000_000)).toBe('1,000,000.00')
22
+ })
23
+
24
+ it('renders dust with a subscript zero count', () => {
25
+ expect(NumberUtils.formatNumber(0.0000001234)).toBe('0.0₆123')
26
+ })
27
+
28
+ it('accepts a decimal-like object', () => {
29
+ expect(NumberUtils.formatNumber(decimalLike)).toBe('1.50')
30
+ })
31
+
32
+ it('returns an empty string for null', () => {
33
+ expect(NumberUtils.formatNumber(null)).toBe('')
34
+ })
35
+
36
+ it('keeps the sign on a tiny number when exponents are off', () => {
37
+ expect(
38
+ NumberUtils.formatNumber(-0.0000001234, { showExponents: false }),
39
+ ).toBe('-0.0000001234')
40
+ })
41
+
42
+ it('honors abbreviation options', () => {
43
+ expect(
44
+ NumberUtils.formatNumber(1_000_000_000, { abbrThreshold: 1e12 }),
45
+ ).toBe('1,000,000,000.00')
46
+ expect(
47
+ NumberUtils.formatNumber(1_000_000_000, { abbreviateLargeNumbers: false }),
48
+ ).toBe('1,000,000,000.00')
49
+ })
50
+
51
+ it('shows a minus by default and a plus only when asked', () => {
52
+ expect(NumberUtils.formatNumber(-1.2)).toBe('-1.20')
53
+ expect(NumberUtils.formatNumber(-1.2, { showNegativeSign: false })).toBe(
54
+ '1.20',
55
+ )
56
+ expect(NumberUtils.formatNumber(1.2, { showPositiveSign: true })).toBe(
57
+ '+1.20',
58
+ )
59
+ expect(NumberUtils.formatNumber(0, { showPositiveSign: true })).toBe('0.00')
60
+ })
61
+ })
62
+
63
+ describe('NumberUtils.abbreviateNumber', () => {
64
+ it('uses the suffix of an exact tier and promotes a rounded 1000', () => {
65
+ expect(NumberUtils.abbreviateNumber(1_000_000, 2)).toBe('1M')
66
+ expect(NumberUtils.abbreviateNumber(999_500, 0)).toBe('1M')
67
+ })
68
+ })
69
+
70
+ describe('NumberUtils presets', () => {
71
+ it('formats a crypto balance with the general formatter', () => {
72
+ expect(NumberUtils.formatCryptoBalance(1234.5)).toBe('1,234.50')
73
+ expect(NumberUtils.formatCryptoBalance(0.0000001234)).toBe('0.0₆123')
74
+ })
75
+
76
+ it('formats a USD balance with a leading dollar sign and two decimals', () => {
77
+ expect(NumberUtils.formatUsdBalance(1234.5)).toBe('$1,234.50')
78
+ expect(NumberUtils.formatUsdBalance(-1.2)).toBe('-$1.20')
79
+ expect(NumberUtils.formatUsdBalance(0)).toBe('$0.00')
80
+ expect(NumberUtils.formatUsdBalance(12_345_678)).toBe('$12.3M')
81
+ })
82
+
83
+ it('formats abbreviated USD', () => {
84
+ expect(NumberUtils.formatUsdCompact(1.5)).toBe('$1.50')
85
+ expect(NumberUtils.formatUsdCompact(1234.5)).toBe('$1.23K')
86
+ expect(NumberUtils.formatUsdCompact(-12_345_678)).toBe('-$12.35M')
87
+ expect(NumberUtils.formatUsdCompact(null)).toBe('$0.00')
88
+ })
89
+
90
+ it('can hide the dollar sign and choose the sign character', () => {
91
+ expect(
92
+ NumberUtils.formatUsdBalance(1234.5, { showCurrencySymbol: false }),
93
+ ).toBe('1,234.50')
94
+ expect(
95
+ NumberUtils.formatUsdBalance(-1.2, {
96
+ showCurrencySymbol: false,
97
+ showNegativeSign: false,
98
+ }),
99
+ ).toBe('1.20')
100
+ expect(
101
+ NumberUtils.formatUsdBalance(1.2, { showPositiveSign: true }),
102
+ ).toBe('+$1.20')
103
+ expect(
104
+ NumberUtils.formatUsdCompact(1234.5, { showCurrencySymbol: false }),
105
+ ).toBe('1.23K')
106
+ expect(
107
+ NumberUtils.formatUsdCompact(-12_345_678, { showNegativeSign: false }),
108
+ ).toBe('$12.35M')
109
+ expect(NumberUtils.formatUsdCompact(1.5, { showPositiveSign: true })).toBe(
110
+ '+$1.50',
111
+ )
112
+ })
113
+
114
+ it('formats a percentage with at most two decimals and no subscript', () => {
115
+ expect(NumberUtils.formatPercentage(12.3)).toBe('12.3')
116
+ expect(NumberUtils.formatPercentage(12.345)).toBe('12.35')
117
+ expect(NumberUtils.formatPercentage(0.0000001234)).toBe('0')
118
+ })
119
+
120
+ it('formats an integer', () => {
121
+ expect(NumberUtils.formatInteger(6737233.2323223)).toBe('6,737,233')
122
+ expect(
123
+ NumberUtils.formatInteger(12_345_678, { abbreviateLargeNumbers: false }),
124
+ ).toBe('12,345,678')
125
+ })
126
+ })
127
+
128
+ describe('NumberUtils.formatBigInt', () => {
129
+ it('formats atomic units with the general formatter', () => {
130
+ expect(NumberUtils.formatBigInt(1_500000n, 6)).toBe('1.50')
131
+ })
132
+ })
133
+
134
+ describe('NumberUtils.expToString', () => {
135
+ it('expands scientific notation', () => {
136
+ expect(NumberUtils.expToString(1e23)).toBe('100000000000000000000000')
137
+ expect(NumberUtils.expToString(-1e-7)).toBe('-0.0000001')
138
+ expect(NumberUtils.expToString('1e23')).toBe('100000000000000000000000')
139
+ expect(NumberUtils.expToString('100000000000000000000000')).toBe(
140
+ '100000000000000000000000',
141
+ )
142
+ })
143
+ })
144
+
145
+ describe('NumberUtils.computePercentile', () => {
146
+ it('interpolates', () => {
147
+ expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 50)).toBe(30)
148
+ expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 75)).toBe(40)
149
+ expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 95)).toBe(48)
150
+ })
151
+
152
+ it('returns null for an empty list', () => {
153
+ expect(NumberUtils.computePercentile([], 50)).toBeNull()
154
+ })
155
+ })
156
+
157
+ describe('NumberUtils.formatSwapAmount', () => {
158
+ it('keeps three digits after leading zeros', () => {
159
+ expect(NumberUtils.formatSwapAmount(0.000015045814)).toBe('0.0000150')
160
+ expect(NumberUtils.formatSwapAmount(1.5e-7)).toBe('0.00000015')
161
+ expect(NumberUtils.formatSwapAmount(0.15045814096)).toBe('0.150')
162
+ })
163
+
164
+ it('adds a plus only when asked and can hide the negative marker', () => {
165
+ expect(
166
+ NumberUtils.formatSwapAmount(0.15045814096, { showPositiveSign: true }),
167
+ ).toBe('+0.150')
168
+ expect(NumberUtils.formatSwapAmount(-1, { showNegativeSign: false })).toBe(
169
+ '0',
170
+ )
171
+ expect(NumberUtils.formatSwapAmount(-1)).toBe('<0')
172
+ })
173
+ })
174
+
175
+ describe('NumberUtils sign options on abbreviation', () => {
176
+ it('applies to abbreviateNumber and formatLargeNumber', () => {
177
+ expect(NumberUtils.abbreviateNumber(-1500, 2)).toBe('-1.5K')
178
+ expect(
179
+ NumberUtils.abbreviateNumber(-1500, 2, false, { showNegativeSign: false }),
180
+ ).toBe('1.5K')
181
+ expect(NumberUtils.formatLargeNumber(1500, { showPositiveSign: true })).toBe(
182
+ '+1.5K',
183
+ )
184
+ })
185
+ })