@ledgerhq/live-countervalues 0.1.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.
Files changed (99) hide show
  1. package/.eslintrc.js +33 -0
  2. package/.turbo/turbo-build.log +4 -0
  3. package/.unimportedrc.json +4 -0
  4. package/CHANGELOG.md +1 -0
  5. package/LICENSE.txt +21 -0
  6. package/jest-global-setup.js +9 -0
  7. package/jest.config.js +14 -0
  8. package/lib/api/api.d.ts +4 -0
  9. package/lib/api/api.d.ts.map +1 -0
  10. package/lib/api/api.js +82 -0
  11. package/lib/api/api.js.map +1 -0
  12. package/lib/api/api.mock.d.ts +4 -0
  13. package/lib/api/api.mock.d.ts.map +1 -0
  14. package/lib/api/api.mock.js +103 -0
  15. package/lib/api/api.mock.js.map +1 -0
  16. package/lib/api/index.d.ts +4 -0
  17. package/lib/api/index.d.ts.map +1 -0
  18. package/lib/api/index.js +19 -0
  19. package/lib/api/index.js.map +1 -0
  20. package/lib/helpers.d.ts +31 -0
  21. package/lib/helpers.d.ts.map +1 -0
  22. package/lib/helpers.js +77 -0
  23. package/lib/helpers.js.map +1 -0
  24. package/lib/logic.d.ts +40 -0
  25. package/lib/logic.d.ts.map +1 -0
  26. package/lib/logic.integration.test.d.ts +2 -0
  27. package/lib/logic.integration.test.d.ts.map +1 -0
  28. package/lib/logic.integration.test.js +191 -0
  29. package/lib/logic.integration.test.js.map +1 -0
  30. package/lib/logic.js +422 -0
  31. package/lib/logic.js.map +1 -0
  32. package/lib/logic.test.d.ts +2 -0
  33. package/lib/logic.test.d.ts.map +1 -0
  34. package/lib/logic.test.js +29 -0
  35. package/lib/logic.test.js.map +1 -0
  36. package/lib/mock.d.ts +4 -0
  37. package/lib/mock.d.ts.map +1 -0
  38. package/lib/mock.js +10 -0
  39. package/lib/mock.js.map +1 -0
  40. package/lib/mock.test.d.ts +2 -0
  41. package/lib/mock.test.d.ts.map +1 -0
  42. package/lib/mock.test.js +210 -0
  43. package/lib/mock.test.js.map +1 -0
  44. package/lib/types.d.ts +47 -0
  45. package/lib/types.d.ts.map +1 -0
  46. package/lib/types.js +4 -0
  47. package/lib/types.js.map +1 -0
  48. package/lib-es/api/api.d.ts +4 -0
  49. package/lib-es/api/api.d.ts.map +1 -0
  50. package/lib-es/api/api.js +77 -0
  51. package/lib-es/api/api.js.map +1 -0
  52. package/lib-es/api/api.mock.d.ts +4 -0
  53. package/lib-es/api/api.mock.d.ts.map +1 -0
  54. package/lib-es/api/api.mock.js +98 -0
  55. package/lib-es/api/api.mock.js.map +1 -0
  56. package/lib-es/api/index.d.ts +4 -0
  57. package/lib-es/api/index.d.ts.map +1 -0
  58. package/lib-es/api/index.js +14 -0
  59. package/lib-es/api/index.js.map +1 -0
  60. package/lib-es/helpers.d.ts +31 -0
  61. package/lib-es/helpers.d.ts.map +1 -0
  62. package/lib-es/helpers.js +67 -0
  63. package/lib-es/helpers.js.map +1 -0
  64. package/lib-es/logic.d.ts +40 -0
  65. package/lib-es/logic.d.ts.map +1 -0
  66. package/lib-es/logic.integration.test.d.ts +2 -0
  67. package/lib-es/logic.integration.test.d.ts.map +1 -0
  68. package/lib-es/logic.integration.test.js +186 -0
  69. package/lib-es/logic.integration.test.js.map +1 -0
  70. package/lib-es/logic.js +406 -0
  71. package/lib-es/logic.js.map +1 -0
  72. package/lib-es/logic.test.d.ts +2 -0
  73. package/lib-es/logic.test.d.ts.map +1 -0
  74. package/lib-es/logic.test.js +27 -0
  75. package/lib-es/logic.test.js.map +1 -0
  76. package/lib-es/mock.d.ts +4 -0
  77. package/lib-es/mock.d.ts.map +1 -0
  78. package/lib-es/mock.js +6 -0
  79. package/lib-es/mock.js.map +1 -0
  80. package/lib-es/mock.test.d.ts +2 -0
  81. package/lib-es/mock.test.d.ts.map +1 -0
  82. package/lib-es/mock.test.js +205 -0
  83. package/lib-es/mock.test.js.map +1 -0
  84. package/lib-es/types.d.ts +47 -0
  85. package/lib-es/types.d.ts.map +1 -0
  86. package/lib-es/types.js +3 -0
  87. package/lib-es/types.js.map +1 -0
  88. package/package.json +81 -0
  89. package/src/api/api.mock.ts +117 -0
  90. package/src/api/api.ts +79 -0
  91. package/src/api/index.ts +19 -0
  92. package/src/helpers.ts +82 -0
  93. package/src/logic.integration.test.ts +209 -0
  94. package/src/logic.test.ts +30 -0
  95. package/src/logic.ts +533 -0
  96. package/src/mock.test.ts +231 -0
  97. package/src/mock.ts +8 -0
  98. package/src/types.ts +70 -0
  99. package/tsconfig.json +15 -0
package/src/logic.ts ADDED
@@ -0,0 +1,533 @@
1
+ import { log } from "@ledgerhq/logs";
2
+ import {
3
+ flattenAccounts,
4
+ getAccountCurrency,
5
+ isAccountEmpty,
6
+ } from "@ledgerhq/coin-framework/account/helpers";
7
+ import { promiseAllBatched } from "@ledgerhq/live-promise";
8
+ import type {
9
+ CounterValuesState,
10
+ CounterValuesStateRaw,
11
+ CountervaluesSettings,
12
+ TrackingPair,
13
+ RateMap,
14
+ RateGranularity,
15
+ PairRateMapCache,
16
+ RateMapRaw,
17
+ } from "./types";
18
+ import {
19
+ pairId,
20
+ magFromTo,
21
+ formatPerGranularity,
22
+ formatCounterValueDay,
23
+ formatCounterValueHashes,
24
+ parseFormattedDate,
25
+ incrementPerGranularity,
26
+ datapointLimits,
27
+ } from "./helpers";
28
+ import type { Account } from "@ledgerhq/types-live";
29
+ import type { Currency } from "@ledgerhq/types-cryptoassets";
30
+ import api from "./api";
31
+
32
+ // yield raw version of the countervalues state to be saved in a db
33
+ export function exportCountervalues({ data, status }: CounterValuesState): CounterValuesStateRaw {
34
+ const out = { status } as CounterValuesStateRaw;
35
+
36
+ for (const path in data) {
37
+ const obj: RateMapRaw = {};
38
+
39
+ for (const [k, v] of data[path]) {
40
+ obj[k] = v;
41
+ }
42
+
43
+ out[path] = obj;
44
+ }
45
+
46
+ return out;
47
+ }
48
+
49
+ // restore a countervalues state from the raw version
50
+ export function importCountervalues(
51
+ { status, ...rest }: CounterValuesStateRaw,
52
+ settings: CountervaluesSettings,
53
+ ): CounterValuesState {
54
+ const data: Record<string, RateMap> = {};
55
+
56
+ for (const path in rest) {
57
+ const obj = rest[path];
58
+ const map = new Map();
59
+
60
+ for (const k in obj) {
61
+ map.set(k, obj[k]);
62
+ }
63
+
64
+ data[path] = map;
65
+ }
66
+
67
+ return {
68
+ data,
69
+ status,
70
+ cache: Object.entries(data).reduce(
71
+ (prev, [key, val]) => ({
72
+ ...prev,
73
+ // $FlowFixMe
74
+ [key]: generateCache(key, <RateMap>val, settings),
75
+ }),
76
+ {},
77
+ ),
78
+ };
79
+ }
80
+
81
+ // infer the tracking pair from user accounts to know which pairs are concerned
82
+ export function inferTrackingPairForAccounts(
83
+ accounts: Account[],
84
+ countervalue: Currency,
85
+ ): TrackingPair[] {
86
+ const yearAgo = new Date();
87
+ yearAgo.setFullYear(yearAgo.getFullYear() - 1);
88
+ yearAgo.setHours(0, 0, 0, 0);
89
+
90
+ return resolveTrackingPairs(
91
+ flattenAccounts(accounts)
92
+ .filter(a => !isAccountEmpty(a))
93
+ .map(account => {
94
+ const currency = getAccountCurrency(account);
95
+ return {
96
+ from: currency,
97
+ to: countervalue,
98
+ startDate: account.creationDate < yearAgo ? account.creationDate : yearAgo,
99
+ };
100
+ }),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * yield the ids of the tracking pairs as stored in the database
106
+ */
107
+ export function trackingPairIds(trackingPairs: TrackingPair[]): string[] {
108
+ return trackingPairs.map(pairId);
109
+ }
110
+
111
+ export const initialState: CounterValuesState = {
112
+ data: {},
113
+ status: {},
114
+ cache: {},
115
+ };
116
+
117
+ const MAX_RETRY_DELAY = 7 * incrementPerGranularity.daily;
118
+ // synchronize all countervalues incrementally (async update of the countervalues state)
119
+ export async function loadCountervalues(
120
+ state: CounterValuesState,
121
+ settings: CountervaluesSettings,
122
+ ): Promise<CounterValuesState> {
123
+ const data = { ...state.data };
124
+ const cache = { ...state.cache };
125
+ const status = { ...state.status };
126
+ const nowDate = new Date();
127
+ const latestToFetch = settings.trackingPairs;
128
+
129
+ // determines what historical data need to be fetched
130
+ const histoToFetch: [
131
+ RateGranularity,
132
+ { from: Currency; to: Currency; startDate: Date },
133
+ string,
134
+ ][] = [];
135
+
136
+ const rateGranularities: RateGranularity[] = ["daily", "hourly"];
137
+
138
+ rateGranularities.forEach((granularity: RateGranularity) => {
139
+ const format = formatPerGranularity[granularity];
140
+ const earliestHisto = format(nowDate);
141
+ log("countervalues", "earliestHisto=" + earliestHisto);
142
+ const limit = datapointLimits[granularity];
143
+
144
+ settings.trackingPairs.forEach(({ from, to, startDate }) => {
145
+ const key = pairId({
146
+ from,
147
+ to,
148
+ });
149
+
150
+ const c: PairRateMapCache | null | undefined = cache[key];
151
+ const stats = c?.stats;
152
+ const s = status[key];
153
+
154
+ // when there are too much http failures, slow down the rate to be actually re-fetched
155
+ if (s?.failures && s.timestamp) {
156
+ const { failures, timestamp } = s;
157
+ const secondsBetweenRetries = Math.min(Math.exp(failures * 0.5), MAX_RETRY_DELAY);
158
+ const nextTarget = timestamp + 1000 * secondsBetweenRetries;
159
+
160
+ if (nowDate.valueOf() < nextTarget) {
161
+ log(
162
+ "countervalues",
163
+ `${key}@${granularity} discarded: too much HTTP failures (${failures}) retry in ~${Math.round(
164
+ (nextTarget - nowDate.valueOf()) / 1000,
165
+ )}s`,
166
+ );
167
+ return;
168
+ }
169
+ }
170
+
171
+ let start = startDate;
172
+ const limitDate = Date.now() - limit;
173
+
174
+ if (limitDate && start.valueOf() < limitDate) {
175
+ start = new Date(limitDate);
176
+ }
177
+
178
+ const needOlderReload = s && s.oldestDateRequested && start < new Date(s.oldestDateRequested);
179
+
180
+ if (needOlderReload) {
181
+ log(
182
+ "countervalues",
183
+ `${key}@${granularity} need older reload (${start.toISOString()} < ${String(
184
+ s && s.oldestDateRequested,
185
+ )})`,
186
+ );
187
+ }
188
+
189
+ if (!needOlderReload) {
190
+ // we do not miss datapoints in the past so we can ask the only remaining part
191
+ if (stats && stats.earliestStableDate && stats.earliestStableDate > start) {
192
+ start = stats.earliestStableDate;
193
+ }
194
+ }
195
+
196
+ // nothing to fetch for historical
197
+ if (format(start) === earliestHisto) return;
198
+ histoToFetch.push([
199
+ granularity,
200
+ {
201
+ from,
202
+ to,
203
+ startDate: start,
204
+ },
205
+ key,
206
+ ]);
207
+ });
208
+ });
209
+
210
+ log(
211
+ "countervalues",
212
+ `${histoToFetch.length} historical value to fetch (${settings.trackingPairs.length} pairs)`,
213
+ );
214
+
215
+ // Fetch it all
216
+ const [histo, latest] = await Promise.all([
217
+ promiseAllBatched(10, histoToFetch, ([granularity, pair, key]) =>
218
+ api
219
+ .fetchHistorical(granularity, pair)
220
+ .then(rates => {
221
+ // Update status infos
222
+ const id = pairId(pair);
223
+ let oldestDateRequested = status[id]?.oldestDateRequested;
224
+
225
+ if (!oldestDateRequested || pair.startDate < new Date(oldestDateRequested)) {
226
+ oldestDateRequested = pair.startDate.toISOString();
227
+ }
228
+
229
+ status[id] = {
230
+ timestamp: Date.now(),
231
+ oldestDateRequested,
232
+ };
233
+
234
+ return {
235
+ [key]: rates,
236
+ };
237
+ })
238
+ .catch(e => {
239
+ if (settings.disableAutoRecoverErrors) throw e;
240
+ // TODO work on the semantic of failure.
241
+ // do we want to opt-in for the 404 cases and make other fails it all?
242
+ // do we want to be resilient on individual pulling / keep error somewhere?
243
+ const id = pairId(pair);
244
+
245
+ // only on HTTP error, we count the failures (not network down case)
246
+ if (e && typeof e.status === "number" && e.status) {
247
+ const s = status[id];
248
+ status[id] = {
249
+ timestamp: Date.now(),
250
+ failures: (s?.failures || 0) + 1,
251
+ oldestDateRequested: s?.oldestDateRequested,
252
+ };
253
+ }
254
+
255
+ log(
256
+ "countervalues-error",
257
+ `Failed to fetch ${granularity} history for ${pair.from.ticker}-${
258
+ pair.to.ticker
259
+ } ${String(e)}`,
260
+ );
261
+ return null;
262
+ }),
263
+ ),
264
+ api
265
+ .fetchLatest(latestToFetch)
266
+ .then(rates => {
267
+ const out: Record<string, { latest: number | null | undefined }> = {};
268
+ let hasData = false;
269
+ latestToFetch.forEach((pair, i) => {
270
+ const key = pairId(pair);
271
+ const latest = rates[i];
272
+ if (data[key]?.get("latest") === latest) return;
273
+ out[key] = {
274
+ latest: rates[i],
275
+ };
276
+ hasData = true;
277
+ });
278
+ if (!hasData) return null;
279
+ return out;
280
+ })
281
+ .catch(e => {
282
+ if (settings.disableAutoRecoverErrors) throw e;
283
+ log(
284
+ "countervalues-error",
285
+ "Failed to fetch latest for " +
286
+ latestToFetch.map(p => `${p.from.ticker}-${p.to.ticker}`).join(",") +
287
+ " " +
288
+ String(e),
289
+ );
290
+ return null;
291
+ }),
292
+ ]);
293
+
294
+ const updates = [];
295
+ for (const patch of histo) {
296
+ if (patch) {
297
+ updates.push(patch);
298
+ }
299
+ }
300
+ if (latest) {
301
+ updates.push(latest);
302
+ }
303
+ log("countervalues", updates.length + " updates to apply");
304
+ const changesKeys: Record<string, unknown> = {};
305
+ updates.forEach(patch => {
306
+ Object.keys(patch).forEach(key => {
307
+ changesKeys[key] = 1;
308
+
309
+ if (!data[key]) {
310
+ data[key] = new Map();
311
+ }
312
+
313
+ Object.entries(patch[key]).forEach(([k, v]) => {
314
+ if (typeof v === "number") data[key].set(k, v);
315
+ });
316
+ });
317
+ });
318
+
319
+ // synchronize the cache
320
+ Object.keys(changesKeys).forEach(pair => {
321
+ cache[pair] = generateCache(pair, data[pair], settings);
322
+ });
323
+
324
+ return {
325
+ data,
326
+ cache,
327
+ status,
328
+ };
329
+ }
330
+
331
+ export function lenseRateMap(
332
+ state: CounterValuesState,
333
+ pair: {
334
+ from: Currency;
335
+ to: Currency;
336
+ },
337
+ ): PairRateMapCache | null | undefined {
338
+ const rateId = pairId(pair);
339
+ return state.cache[rateId];
340
+ }
341
+ export function lenseRate(
342
+ { stats, fallback, map }: PairRateMapCache,
343
+ query: {
344
+ from: Currency;
345
+ to: Currency;
346
+ date?: Date | null | undefined;
347
+ },
348
+ ): number | null | undefined {
349
+ const { date } = query;
350
+ if (!date) return map.get("latest");
351
+ const { iso, hour, day } = formatCounterValueHashes(date);
352
+ if (stats.earliest && iso > stats.earliest) return map.get("latest");
353
+ return map.get(hour) || map.get(day) || fallback;
354
+ }
355
+ export function calculate(
356
+ state: CounterValuesState,
357
+ initialQuery: {
358
+ value: number;
359
+ from: Currency;
360
+ to: Currency;
361
+ disableRounding?: boolean;
362
+ date?: Date | null | undefined;
363
+ reverse?: boolean;
364
+ },
365
+ ): number | null | undefined {
366
+ const { from, to } = initialQuery;
367
+ if (from === to) return initialQuery.value;
368
+ const { date, value, disableRounding, reverse } = initialQuery;
369
+ const query = {
370
+ date,
371
+ from,
372
+ to,
373
+ };
374
+ const map = lenseRateMap(state, query);
375
+ if (!map) return;
376
+ let rate = lenseRate(map, query);
377
+ if (!rate) return;
378
+ const mult = reverse
379
+ ? magFromTo(initialQuery.to, initialQuery.from)
380
+ : magFromTo(initialQuery.from, initialQuery.to);
381
+
382
+ if (reverse && rate) {
383
+ rate = 1 / rate;
384
+ }
385
+
386
+ const val = rate ? value * rate * mult : 0;
387
+ return disableRounding ? val : Math.round(val);
388
+ }
389
+
390
+ export function calculateMany(
391
+ state: CounterValuesState,
392
+ dataPoints: Array<{
393
+ value: number;
394
+ date: Date | null | undefined;
395
+ }>,
396
+ initialQuery: {
397
+ from: Currency;
398
+ to: Currency;
399
+ disableRounding?: boolean;
400
+ reverse?: boolean;
401
+ },
402
+ ): Array<number | null | undefined> {
403
+ const { reverse, disableRounding } = initialQuery;
404
+ const { from, to } = initialQuery;
405
+ if (from === to) return dataPoints.map(d => d.value);
406
+ const map = lenseRateMap(state, initialQuery);
407
+ if (!map) return Array(dataPoints.length).fill(undefined); // undefined array
408
+
409
+ const mult = reverse
410
+ ? magFromTo(initialQuery.to, initialQuery.from)
411
+ : magFromTo(initialQuery.from, initialQuery.to);
412
+ return dataPoints.map(({ value, date }) => {
413
+ if (from === to) return value;
414
+ let rate = lenseRate(map, {
415
+ from,
416
+ to,
417
+ date,
418
+ });
419
+ if (!rate) return;
420
+
421
+ if (reverse && rate) {
422
+ rate = 1 / rate;
423
+ }
424
+
425
+ const val = rate ? value * rate * mult : 0;
426
+ return disableRounding ? val : Math.round(val);
427
+ });
428
+ }
429
+
430
+ function generateCache(
431
+ pair: string,
432
+ rateMap: RateMap,
433
+ settings: CountervaluesSettings,
434
+ ): PairRateMapCache {
435
+ const map = new Map(rateMap);
436
+ const sorted = Array.from(map.keys())
437
+ .sort()
438
+ .filter(k => k !== "latest");
439
+ const oldest = sorted[0];
440
+ const earliest = sorted[sorted.length - 1];
441
+ const oldestDate = oldest ? parseFormattedDate(oldest) : null;
442
+ const earliestDate = earliest ? parseFormattedDate(earliest) : null;
443
+ let earliestStableDate = earliestDate;
444
+ let fallback: number = 0;
445
+ let hasHole = false;
446
+
447
+ if (oldestDate && oldest) {
448
+ // we find the most recent stable day and we set it in earliestStableDate
449
+ // if autofillGaps is on, shifting daily gaps (hourly don't need to be shifted as it automatically fallback on a day rate)
450
+ const now = Date.now();
451
+ const oldestTime = oldestDate.getTime();
452
+ let shiftingValue = map.get(oldest) || 0;
453
+
454
+ if (settings.autofillGaps) {
455
+ fallback = shiftingValue;
456
+ }
457
+
458
+ for (let t = oldestTime; t < now; t += incrementPerGranularity.daily) {
459
+ const d = new Date(t);
460
+ const k = formatCounterValueDay(d);
461
+
462
+ if (!map.has(k)) {
463
+ if (!hasHole) {
464
+ hasHole = true;
465
+ earliestStableDate = d;
466
+ }
467
+
468
+ if (settings.autofillGaps) {
469
+ map.set(k, shiftingValue);
470
+ }
471
+ } else {
472
+ if (settings.autofillGaps) {
473
+ shiftingValue = map.get(k) || 0;
474
+ }
475
+ }
476
+ }
477
+
478
+ if (!map.get("latest") && settings.autofillGaps) {
479
+ map.set("latest", shiftingValue);
480
+ }
481
+ } else {
482
+ if (settings.autofillGaps) {
483
+ fallback = map.get("latest") || 0;
484
+ }
485
+ }
486
+
487
+ const stats = {
488
+ oldest,
489
+ earliest,
490
+ oldestDate,
491
+ earliestDate,
492
+ earliestStableDate,
493
+ };
494
+
495
+ return {
496
+ map,
497
+ stats,
498
+ fallback,
499
+ };
500
+ }
501
+
502
+ export function resolveTrackingPairs(pairs: TrackingPair[]): TrackingPair[] {
503
+ const trackingPairs: Record<string, TrackingPair> = {};
504
+
505
+ for (const pair of pairs) {
506
+ const { from, to } = pair;
507
+
508
+ if (from === to) continue;
509
+
510
+ // dedup and keep oldest date
511
+ let date = pair.startDate;
512
+ const id = pairId(pair);
513
+
514
+ if (trackingPairs[id]) {
515
+ const { startDate } = trackingPairs[id];
516
+
517
+ if (date) {
518
+ date = date < startDate ? date : startDate;
519
+ }
520
+ }
521
+
522
+ trackingPairs[id] = {
523
+ from,
524
+ to,
525
+ startDate: date,
526
+ };
527
+ }
528
+
529
+ // to reach more deterministic order, notably in API calls, we sort by from/to
530
+ return Object.keys(trackingPairs)
531
+ .sort()
532
+ .map(id => trackingPairs[id]);
533
+ }