@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.
@@ -0,0 +1,398 @@
1
+ import { coerceNumber, type DecimalLike, type NumberInput } from './decimal.js'
2
+ import { formatUnits, parseUnits } from './units.js'
3
+
4
+ export type FormatNumberOptions = {
5
+ showExponents?: boolean
6
+ minSignificantDigits?: number
7
+ maxDecimals?: number
8
+ minDecimals?: number
9
+ abbrThreshold?: number
10
+ prefix?: string
11
+ abbreviateLargeNumbers?: boolean
12
+ abbrMinSignificantDigits?: number
13
+ }
14
+
15
+ export type UsdInput = NumberInput | string
16
+
17
+ export const expToString = (value: string | number): string => {
18
+ const raw = value.toString()
19
+ const negative = raw.startsWith('-')
20
+ const expanded = expandUnsignedExponent(negative ? raw.slice(1) : raw)
21
+ if (!negative || expanded === '0') return expanded
22
+ return `-${expanded}`
23
+ }
24
+
25
+ const expandUnsignedExponent = (expString: string): string => {
26
+ const [maybeNumber, exponent] = expString.split('e')
27
+ const number = maybeNumber ?? '0'
28
+
29
+ if (!exponent) return number
30
+ const decimalPosition = number.indexOf('.')
31
+
32
+ const removedDecimalNumber = number.replace('.', '')
33
+
34
+ const actualDecimalPosition =
35
+ decimalPosition === -1 ? number.length : decimalPosition
36
+ const decimalMovement = actualDecimalPosition + parseInt(exponent, 10)
37
+
38
+ const additionaltrailingZeros = Math.max(
39
+ decimalMovement - removedDecimalNumber.length,
40
+ 0,
41
+ )
42
+
43
+ const additionalLeadingZeros =
44
+ actualDecimalPosition + parseInt(exponent, 10) < 0
45
+ ? Math.abs(decimalMovement)
46
+ : 0
47
+
48
+ const numberArr = Array(additionalLeadingZeros)
49
+ .fill('0')
50
+ .concat(removedDecimalNumber.split(''))
51
+ .concat(Array(additionaltrailingZeros).fill('0'))
52
+
53
+ const newDecimalPosition = Math.max(decimalMovement, 0)
54
+
55
+ if (newDecimalPosition < numberArr.length) {
56
+ const decimalInsertion = newDecimalPosition === 0 ? '0.' : '.'
57
+ numberArr.splice(newDecimalPosition, 0, decimalInsertion)
58
+ }
59
+
60
+ return numberArr.join('')
61
+ }
62
+
63
+ function getMagnitude(n: number) {
64
+ if (n === 0) return 0
65
+ return Math.floor(Math.log10(Math.abs(n)))
66
+ }
67
+
68
+ function toSubscript(text: string | number) {
69
+ const subscriptMap: Record<string, string> = {
70
+ '0': '₀',
71
+ '1': '₁',
72
+ '2': '₂',
73
+ '3': '₃',
74
+ '4': '₄',
75
+ '5': '₅',
76
+ '6': '₆',
77
+ '7': '₇',
78
+ '8': '₈',
79
+ '9': '₉',
80
+ }
81
+
82
+ let result = ''
83
+ for (const char of expToString(text)) {
84
+ result += subscriptMap[char] || char
85
+ }
86
+ return result
87
+ }
88
+
89
+ const abbreviateNumber = (
90
+ num: number,
91
+ decimals = 2,
92
+ preserveTrailingZeros = false,
93
+ ) => {
94
+ const lookup = [
95
+ { value: 1, symbol: '' },
96
+ { value: 1e3, symbol: 'K' },
97
+ { value: 1e6, symbol: 'M' },
98
+ { value: 1e9, symbol: 'B' },
99
+ { value: 1e12, symbol: 'T' },
100
+ { value: 1e15, symbol: 'P' },
101
+ { value: 1e18, symbol: 'E' },
102
+ ]
103
+
104
+ const rx = /\.0+$|(\.[0-9]*[1-9])0+$/
105
+ const formatFixed = (fixed: string) =>
106
+ preserveTrailingZeros ? fixed : fixed.replace(rx, '$1')
107
+
108
+ if (!Number.isFinite(num)) return formatFixed(num.toFixed(decimals))
109
+
110
+ const absNum = Math.abs(num)
111
+ let tier = 0
112
+ for (let i = lookup.length - 1; i >= 0; i--) {
113
+ const entry = lookup[i]
114
+ if (entry && absNum >= entry.value) {
115
+ tier = i
116
+ break
117
+ }
118
+ }
119
+
120
+ const renderTier = (index: number) => {
121
+ const entry = lookup[index]
122
+ if (!entry) return { text: formatFixed(num.toFixed(decimals)), rounded: num }
123
+ const rounded = Number((num / entry.value).toFixed(decimals) || 0)
124
+ const formattedNumber = rounded.toLocaleString('en-us', {
125
+ minimumFractionDigits: decimals,
126
+ maximumFractionDigits: decimals,
127
+ })
128
+ return { text: formatFixed(formattedNumber) + entry.symbol, rounded }
129
+ }
130
+
131
+ let rendered = renderTier(tier)
132
+ if (Math.abs(rendered.rounded) >= 1000 && tier < lookup.length - 1) {
133
+ rendered = renderTier(tier + 1)
134
+ }
135
+ return rendered.text
136
+ }
137
+
138
+ const formatNumber = (
139
+ value: NumberInput,
140
+ {
141
+ showExponents = true,
142
+ minSignificantDigits = 4,
143
+ maxDecimals: maxDecimalsParam,
144
+ minDecimals: minDecimalsParam,
145
+ abbrThreshold = 1e7,
146
+ abbreviateLargeNumbers = true,
147
+ abbrMinSignificantDigits = 3,
148
+ prefix = '',
149
+ }: FormatNumberOptions = {},
150
+ ) => {
151
+ let minDecimals: number
152
+ let maxDecimals: number
153
+
154
+ if (maxDecimalsParam == null && minDecimalsParam != null) {
155
+ minDecimals = minDecimalsParam
156
+ maxDecimals = Math.max(6, minDecimalsParam)
157
+ } else if (minDecimalsParam == null && maxDecimalsParam != null) {
158
+ maxDecimals = maxDecimalsParam
159
+ minDecimals = Math.min(2, maxDecimalsParam)
160
+ } else if (maxDecimalsParam != null && minDecimalsParam != null) {
161
+ maxDecimals = maxDecimalsParam
162
+ minDecimals = Math.min(maxDecimalsParam, minDecimalsParam)
163
+ } else {
164
+ maxDecimals = 6
165
+ minDecimals = 2
166
+ }
167
+
168
+ const num = coerceNumber(value)
169
+
170
+ if (num === 0) {
171
+ const zeros = '0'.repeat(minDecimals)
172
+ return `0${zeros ? `.${zeros}` : ''}`
173
+ }
174
+ if (!num) return ''
175
+ const absNumber = Math.abs(num)
176
+
177
+ const magnitude = getMagnitude(num)
178
+
179
+ if (magnitude <= -Math.abs(maxDecimals) && minSignificantDigits) {
180
+ if (!showExponents) {
181
+ const numberToRound =
182
+ Math.abs(num) * 10 ** (Math.abs(magnitude) + minSignificantDigits - 1)
183
+
184
+ const fullNumber = expToString(
185
+ Math.round(numberToRound) /
186
+ 10 ** (Math.abs(magnitude) + minSignificantDigits - 1),
187
+ )
188
+
189
+ const sign = num < 0 ? '-' : ''
190
+ return `${sign}${prefix}${fullNumber}`
191
+ }
192
+ const unsignedEndPart = Math.abs(num) / 10 ** magnitude
193
+
194
+ const formattedEndPart = Number(
195
+ unsignedEndPart.toFixed(minSignificantDigits - 1),
196
+ )
197
+ .toLocaleString('en-us', {
198
+ minimumFractionDigits: minDecimals,
199
+ maximumFractionDigits: Math.max(
200
+ 0,
201
+ Math.min(20, Math.max(minSignificantDigits - 2, minDecimals)),
202
+ ),
203
+ })
204
+ .replace(/\./g, '')
205
+
206
+ const sign = num < 0 ? '-' : ''
207
+
208
+ return `${sign}${prefix}0.0${toSubscript(Math.abs(magnitude) - 1)}${formattedEndPart}`
209
+ }
210
+
211
+ const smallestDisplayableNumber = 1 / 10 ** maxDecimals
212
+
213
+ if (absNumber < smallestDisplayableNumber) {
214
+ const zeros = '0'.repeat(minDecimals)
215
+ return `0${zeros ? `.${zeros}` : ''}`
216
+ }
217
+
218
+ if (abbreviateLargeNumbers && absNumber >= abbrThreshold) {
219
+ const numbersBeforeDecimal = (magnitude + 1) % 3 || 3
220
+ return `${prefix}${abbreviateNumber(
221
+ num,
222
+ Math.max(abbrMinSignificantDigits - numbersBeforeDecimal, 0),
223
+ )}`
224
+ }
225
+
226
+ if (absNumber < 1) {
227
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals))
228
+ return `${prefix}${Number(num.toFixed(maxDecimals)).toLocaleString(
229
+ 'en-us',
230
+ {
231
+ minimumFractionDigits: minDecimals,
232
+ maximumFractionDigits: clampedMaxDecimals,
233
+ },
234
+ )}`
235
+ }
236
+
237
+ if (absNumber >= 1 && absNumber < 10000) {
238
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals))
239
+ return `${prefix}${Number(
240
+ num.toFixed(Math.min(maxDecimals, 2)),
241
+ ).toLocaleString('en-us', {
242
+ minimumFractionDigits: minDecimals,
243
+ maximumFractionDigits: clampedMaxDecimals,
244
+ })}`
245
+ }
246
+
247
+ const rounded = maxDecimals === 0 ? Math.round(num) : num
248
+ const clampedMaxDecimals = Math.max(0, Math.min(20, maxDecimals))
249
+
250
+ return `${prefix}${Number(rounded.toFixed(maxDecimals)).toLocaleString(
251
+ 'en-us',
252
+ {
253
+ minimumFractionDigits: minDecimals,
254
+ maximumFractionDigits: clampedMaxDecimals,
255
+ },
256
+ )}`
257
+ }
258
+
259
+ const USD_BALANCE_PRESET: FormatNumberOptions = {
260
+ minDecimals: 2,
261
+ maxDecimals: 2,
262
+ minSignificantDigits: 0,
263
+ showExponents: false,
264
+ }
265
+
266
+ const PERCENTAGE_PRESET: FormatNumberOptions = {
267
+ minDecimals: 0,
268
+ maxDecimals: 2,
269
+ minSignificantDigits: 0,
270
+ showExponents: false,
271
+ }
272
+
273
+ const INTEGER_PRESET: FormatNumberOptions = {
274
+ minDecimals: 0,
275
+ maxDecimals: 0,
276
+ minSignificantDigits: 0,
277
+ showExponents: false,
278
+ }
279
+
280
+ function mergePreset(
281
+ preset: FormatNumberOptions,
282
+ options: FormatNumberOptions | undefined,
283
+ ): FormatNumberOptions {
284
+ if (options === undefined) return preset
285
+ return { ...preset, ...options }
286
+ }
287
+
288
+ function coerceUsd(value: UsdInput): number | null | undefined {
289
+ if (typeof value === 'string') {
290
+ const parsed = Number.parseFloat(value)
291
+ return Number.isNaN(parsed) ? undefined : parsed
292
+ }
293
+ return coerceNumber(value)
294
+ }
295
+
296
+ function formatSignedUsd(
297
+ value: UsdInput,
298
+ formatAbs: (absolute: number) => string,
299
+ ): string {
300
+ const numeric = coerceUsd(value)
301
+ if (numeric == null || !Number.isFinite(numeric) || numeric === 0)
302
+ return '$0.00'
303
+ const body = formatAbs(Math.abs(numeric))
304
+ return numeric < 0 ? `-$${body}` : `$${body}`
305
+ }
306
+
307
+ const formatUsd = (value: UsdInput) =>
308
+ formatSignedUsd(value, (absolute) => abbreviateNumber(absolute, 2, true))
309
+
310
+ const formatUsdBalance = (value: UsdInput, options?: FormatNumberOptions) =>
311
+ formatSignedUsd(value, (absolute) =>
312
+ formatNumber(absolute, mergePreset(USD_BALANCE_PRESET, options)),
313
+ )
314
+
315
+ const formatCryptoBalance = (
316
+ value: NumberInput,
317
+ options?: FormatNumberOptions,
318
+ ) => formatNumber(value, options)
319
+
320
+ const formatPercentage = (value: NumberInput, options?: FormatNumberOptions) =>
321
+ formatNumber(value, mergePreset(PERCENTAGE_PRESET, options))
322
+
323
+ const formatInteger = (value: NumberInput, options?: FormatNumberOptions) =>
324
+ formatNumber(value, mergePreset(INTEGER_PRESET, options))
325
+
326
+ const formatTokenPrice = (price: NumberInput) => formatNumber(price)
327
+
328
+ const formatBigInt = (
329
+ input: bigint | string | DecimalLike,
330
+ decimals: number,
331
+ formatOptions?: FormatNumberOptions,
332
+ ) => {
333
+ let atomic: bigint
334
+ if (typeof input === 'bigint') {
335
+ atomic = input
336
+ } else if (typeof input === 'string') {
337
+ atomic = BigInt(input)
338
+ } else {
339
+ atomic = parseUnits(input.toFixed(0), 0)
340
+ }
341
+ return formatNumber(Number(formatUnits(atomic, decimals)), formatOptions)
342
+ }
343
+
344
+ const formatLargeNumber = (value: number | DecimalLike) => {
345
+ const numeric = typeof value === 'number' ? value : value.toNumber()
346
+ return abbreviateNumber(numeric, 2)
347
+ }
348
+
349
+ const formatSwapAmount = (value: number | string): string => {
350
+ const number = typeof value === 'string' ? Number.parseFloat(value) : value
351
+
352
+ if (!number || number === 0) return '0'
353
+ if (number < 0) return '<0'
354
+
355
+ const [integerPart, decimalPart = ''] = expToString(number).split('.')
356
+
357
+ if (!decimalPart) return integerPart ?? '0'
358
+
359
+ const leadingZeroLength = (decimalPart.match(/^0+/) || [''])[0].length
360
+ const significantDigits = decimalPart.replace(/^0+/, '').substring(0, 3)
361
+
362
+ if (!significantDigits) return '0'
363
+
364
+ return `${integerPart}.${'0'.repeat(leadingZeroLength)}${significantDigits}`
365
+ }
366
+
367
+ function computePercentile(
368
+ sorted: number[],
369
+ percentile: number,
370
+ ): number | null {
371
+ if (sorted.length === 0) return null
372
+
373
+ const index = (percentile / 100) * (sorted.length - 1)
374
+ const lower = Math.floor(index)
375
+ const upper = Math.ceil(index)
376
+
377
+ if (lower === upper) return sorted[lower] ?? null
378
+
379
+ const lowerValue = sorted[lower] ?? 0
380
+ const upperValue = sorted[upper] ?? lowerValue
381
+ return lowerValue + (upperValue - lowerValue) * (index - lower)
382
+ }
383
+
384
+ export const NumberUtils = {
385
+ formatNumber,
386
+ formatCryptoBalance,
387
+ formatUsdBalance,
388
+ formatPercentage,
389
+ formatUsd,
390
+ formatInteger,
391
+ formatTokenPrice,
392
+ formatBigInt,
393
+ abbreviateNumber,
394
+ formatLargeNumber,
395
+ formatSwapAmount,
396
+ expToString,
397
+ computePercentile,
398
+ }
package/src/units.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `formatUnits` / `parseUnits` behavior from viem's `Value` module.
3
+ * Inlined so this package does not depend on viem.
4
+ */
5
+
6
+ export function formatUnits(value: bigint, decimals: number): string {
7
+ assertDecimals(decimals)
8
+
9
+ let display = value.toString()
10
+ const negative = display.startsWith('-')
11
+ if (negative) display = display.slice(1)
12
+
13
+ display = display.padStart(decimals, '0')
14
+
15
+ const integer = display.slice(0, display.length - decimals)
16
+ let fraction = display.slice(display.length - decimals)
17
+ fraction = fraction.replace(/(0+)$/, '')
18
+ return `${negative ? '-' : ''}${integer || '0'}${fraction ? `.${fraction}` : ''}`
19
+ }
20
+
21
+ export function parseUnits(value: string, decimals: number): bigint {
22
+ assertDecimals(decimals)
23
+
24
+ if (!/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(value)) {
25
+ throw new Error(`\`${value}\` is not a valid decimal number.`)
26
+ }
27
+
28
+ let [integer = '', fraction = '0'] = value.split('.')
29
+
30
+ const negative = integer.startsWith('-')
31
+ if (negative) integer = integer.slice(1)
32
+ if (integer === '') integer = '0'
33
+
34
+ fraction = fraction.replace(/(0+)$/, '')
35
+
36
+ if (decimals === 0) {
37
+ const firstFractionDigit = fraction[0]
38
+ if (
39
+ fraction.length > 0 &&
40
+ firstFractionDigit !== undefined &&
41
+ Number.parseInt(firstFractionDigit, 10) >= 5
42
+ ) {
43
+ integer = `${BigInt(integer) + 1n}`
44
+ }
45
+ fraction = ''
46
+ } else if (fraction.length > decimals) {
47
+ const left = fraction.slice(0, decimals)
48
+ const roundDigitChar = fraction.slice(decimals, decimals + 1)
49
+ const roundDigit = Number.parseInt(roundDigitChar, 10)
50
+
51
+ if (roundDigit >= 5) {
52
+ const carried = carry(left)
53
+ if (carried.length > decimals) {
54
+ fraction = carried.slice(1)
55
+ integer = `${BigInt(integer) + 1n}`
56
+ } else {
57
+ fraction = carried
58
+ }
59
+ } else {
60
+ fraction = left
61
+ }
62
+ } else {
63
+ fraction = fraction.padEnd(decimals, '0')
64
+ }
65
+
66
+ return BigInt(`${negative ? '-' : ''}${integer}${fraction}`)
67
+ }
68
+
69
+ function assertDecimals(decimals: number): void {
70
+ if (!Number.isInteger(decimals) || decimals < 0) {
71
+ throw new Error(
72
+ `decimals must be a non-negative integer. Got \`${decimals}\`.`,
73
+ )
74
+ }
75
+ }
76
+
77
+ function carry(digits: string): string {
78
+ const out = digits.split('')
79
+ let i = out.length - 1
80
+ while (i >= 0) {
81
+ const current = out[i]
82
+ if (current === undefined) break
83
+ const next = Number.parseInt(current, 10) + 1
84
+ if (next < 10) {
85
+ out[i] = String(next)
86
+ return out.join('')
87
+ }
88
+ out[i] = '0'
89
+ i -= 1
90
+ }
91
+ return `1${out.join('')}`
92
+ }