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