@rallycash/numbers 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +7 -0
- package/README.md +229 -2
- package/dist/bigint-utils.d.ts +17 -0
- package/dist/bigint-utils.d.ts.map +1 -0
- package/dist/bigint-utils.js +178 -0
- package/dist/decimal.d.ts +12 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +16 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/number-utils.d.ts +31 -0
- package/dist/number-utils.d.ts.map +1 -0
- package/dist/number-utils.js +276 -0
- package/dist/units.d.ts +7 -0
- package/dist/units.d.ts.map +1 -0
- package/dist/units.js +82 -0
- package/package.json +30 -3
- package/src/bigint-utils.test.ts +173 -0
- package/src/bigint-utils.ts +222 -0
- package/src/decimal.ts +25 -0
- package/src/index.ts +7 -0
- package/src/number-utils.test.ts +125 -0
- package/src/number-utils.ts +398 -0
- package/src/units.ts +92 -0
|
@@ -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,125 @@
|
|
|
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
|
+
|
|
52
|
+
describe('NumberUtils.abbreviateNumber', () => {
|
|
53
|
+
it('uses the suffix of an exact tier and promotes a rounded 1000', () => {
|
|
54
|
+
expect(NumberUtils.abbreviateNumber(1_000_000, 2)).toBe('1M')
|
|
55
|
+
expect(NumberUtils.abbreviateNumber(999_500, 0)).toBe('1M')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('NumberUtils presets', () => {
|
|
60
|
+
it('formats a crypto balance with the general formatter', () => {
|
|
61
|
+
expect(NumberUtils.formatCryptoBalance(1234.5)).toBe('1,234.50')
|
|
62
|
+
expect(NumberUtils.formatCryptoBalance(0.0000001234)).toBe('0.0₆123')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('formats a USD balance with a leading dollar sign and two decimals', () => {
|
|
66
|
+
expect(NumberUtils.formatUsdBalance(1234.5)).toBe('$1,234.50')
|
|
67
|
+
expect(NumberUtils.formatUsdBalance(-1.2)).toBe('-$1.20')
|
|
68
|
+
expect(NumberUtils.formatUsdBalance(0)).toBe('$0.00')
|
|
69
|
+
expect(NumberUtils.formatUsdBalance(12_345_678)).toBe('$12.3M')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('formats abbreviated USD', () => {
|
|
73
|
+
expect(NumberUtils.formatUsd(1.5)).toBe('$1.50')
|
|
74
|
+
expect(NumberUtils.formatUsd(1234.5)).toBe('$1.23K')
|
|
75
|
+
expect(NumberUtils.formatUsd(-12_345_678)).toBe('-$12.35M')
|
|
76
|
+
expect(NumberUtils.formatUsd(null)).toBe('$0.00')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('formats a percentage with at most two decimals and no subscript', () => {
|
|
80
|
+
expect(NumberUtils.formatPercentage(12.3)).toBe('12.3')
|
|
81
|
+
expect(NumberUtils.formatPercentage(12.345)).toBe('12.35')
|
|
82
|
+
expect(NumberUtils.formatPercentage(0.0000001234)).toBe('0')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('formats an integer', () => {
|
|
86
|
+
expect(NumberUtils.formatInteger(6737233.2323223)).toBe('6,737,233')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('NumberUtils.formatBigInt', () => {
|
|
91
|
+
it('formats atomic units with the general formatter', () => {
|
|
92
|
+
expect(NumberUtils.formatBigInt(1_500000n, 6)).toBe('1.50')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('NumberUtils.expToString', () => {
|
|
97
|
+
it('expands scientific notation', () => {
|
|
98
|
+
expect(NumberUtils.expToString(1e23)).toBe('100000000000000000000000')
|
|
99
|
+
expect(NumberUtils.expToString(-1e-7)).toBe('-0.0000001')
|
|
100
|
+
expect(NumberUtils.expToString('1e23')).toBe('100000000000000000000000')
|
|
101
|
+
expect(NumberUtils.expToString('100000000000000000000000')).toBe(
|
|
102
|
+
'100000000000000000000000',
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('NumberUtils.computePercentile', () => {
|
|
108
|
+
it('interpolates', () => {
|
|
109
|
+
expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 50)).toBe(30)
|
|
110
|
+
expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 75)).toBe(40)
|
|
111
|
+
expect(NumberUtils.computePercentile([10, 20, 30, 40, 50], 95)).toBe(48)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns null for an empty list', () => {
|
|
115
|
+
expect(NumberUtils.computePercentile([], 50)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('NumberUtils.formatSwapAmount', () => {
|
|
120
|
+
it('keeps three digits after leading zeros', () => {
|
|
121
|
+
expect(NumberUtils.formatSwapAmount(0.000015045814)).toBe('0.0000150')
|
|
122
|
+
expect(NumberUtils.formatSwapAmount(1.5e-7)).toBe('0.00000015')
|
|
123
|
+
expect(NumberUtils.formatSwapAmount(0.15045814096)).toBe('0.150')
|
|
124
|
+
})
|
|
125
|
+
})
|