@exodus/market-history 10.2.3 → 10.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.3.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/market-history@10.2.4...@exodus/market-history@10.3.0) (2025-07-01)
7
+
8
+ ### Features
9
+
10
+ - feat: merge price-api with market-history (#12962)
11
+
12
+ ## [10.2.4](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/market-history@10.2.3...@exodus/market-history@10.2.4) (2025-06-17)
13
+
14
+ ### Performance
15
+
16
+ - perf(market-history): Optimize price transformation logic (#12932)
17
+
6
18
  ## [10.2.3](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/market-history@10.2.2...@exodus/market-history@10.2.3) (2025-06-16)
7
19
 
8
20
  ### Performance
@@ -0,0 +1,10 @@
1
+ const HOURLY_LIMIT = 24 * 7
2
+ const DAILY_LIMIT = 3000
3
+ const MINUTE_LIMIT = 60 * 2 // we have only 2 hours of minute level data cached
4
+
5
+ export const LIMIT = {
6
+ minute: MINUTE_LIMIT,
7
+ hour: HOURLY_LIMIT,
8
+ day: DAILY_LIMIT,
9
+ }
10
+ export const START_TIME = new Date('2015-12-09').getTime()
@@ -0,0 +1,13 @@
1
+ import ms from 'ms'
2
+ import lastTimestampFromPricesMap from './last-timestamp-from-prices-map.js'
3
+
4
+ const hourMs = ms('1h')
5
+
6
+ const cropHistory = ({ history, hourlyLimit }) => {
7
+ const lastCachedTime = lastTimestampFromPricesMap(history)
8
+ if (!lastCachedTime) return history
9
+ const limitedTime = lastCachedTime - hourMs * hourlyLimit
10
+ return new Map([...history].filter(([key]) => key > limitedTime))
11
+ }
12
+
13
+ export default cropHistory
@@ -0,0 +1,61 @@
1
+ import processApiResponse from './process-api-response.js'
2
+
3
+ export default async function fetchAndProcessTickers({
4
+ assetTickersToFetch,
5
+ api,
6
+ fiatTicker,
7
+ granularity,
8
+ timestamp,
9
+ ignoreInvalidSymbols,
10
+ historicalPricesMap,
11
+ fetchedPricesMap,
12
+ specificTimestamp,
13
+ requestLimit,
14
+ }) {
15
+ if (assetTickersToFetch.length === 0) {
16
+ return
17
+ }
18
+
19
+ const maxLimit = Math.max(...assetTickersToFetch.map(({ limit }) => limit))
20
+
21
+ const requests = [
22
+ assetTickersToFetch.filter(({ limit }) => limit !== maxLimit),
23
+ assetTickersToFetch.filter(({ limit }) => limit === maxLimit),
24
+ ]
25
+
26
+ await Promise.all(
27
+ requests
28
+ .filter((tickers) => tickers.length > 0)
29
+ .map(async (currentTickersToFetch) => {
30
+ const limit = Math.max(...currentTickersToFetch.map(({ limit }) => limit))
31
+ const requestParams = Object.create(null)
32
+ requestParams.assets = currentTickersToFetch.map((item) => item.tickerSymbol)
33
+ requestParams.fiatArray = [fiatTicker]
34
+ requestParams.granularity = granularity
35
+ requestParams.limit = limit
36
+ requestParams.timestamp = timestamp
37
+ requestParams.ignoreInvalidSymbols = ignoreInvalidSymbols
38
+
39
+ const fetchedPrices = await api(requestParams)
40
+
41
+ Object.keys(fetchedPrices).forEach((assetTicker) => {
42
+ const newHistory = processApiResponse({
43
+ assetTicker,
44
+ fiatTicker,
45
+ fetchedPrices,
46
+ assetTickersToFetch,
47
+ ignoreInvalidSymbols,
48
+ historicalPricesMap,
49
+ granularity,
50
+ specificTimestamp,
51
+ requestLimit,
52
+ })
53
+
54
+ if (newHistory) {
55
+ fetchedPricesMap.set(assetTicker, newHistory)
56
+ historicalPricesMap.set(assetTicker, newHistory)
57
+ }
58
+ })
59
+ })
60
+ )
61
+ }
@@ -0,0 +1,88 @@
1
+ import dayjs from '@exodus/dayjs'
2
+ import prepareFetchInstructions from './prepare-fetch-instructions.js'
3
+ import fetchAndProcessTickers from './fetch-and-process-tickers.js'
4
+
5
+ const runtimeCacheDefault = new Map()
6
+ const getRuntimeCacheKeyDefault = ({ fiatTicker, assetTicker, granularity }) =>
7
+ `${assetTicker}_${fiatTicker}_${granularity}`
8
+
9
+ export default async function fetchHistoricalPrices({
10
+ api,
11
+ assetTickers,
12
+ fiatTicker,
13
+ granularity = 'day',
14
+ getCacheFromStorage,
15
+ requestLimit,
16
+ timestamp,
17
+ getCurrentTime = () => Date.now(),
18
+ ignoreInvalidSymbols = false,
19
+ ignoreCache = false,
20
+ runtimeCache = runtimeCacheDefault,
21
+ getRuntimeCacheKey = getRuntimeCacheKeyDefault,
22
+ }) {
23
+ const specificTimestamp = timestamp ? dayjs(timestamp).utc().startOf(granularity).valueOf() : null
24
+
25
+ const requestTimestamp =
26
+ specificTimestamp ||
27
+ dayjs.utc(getCurrentTime()).subtract(1, granularity).startOf(granularity).valueOf()
28
+
29
+ const historicalPricesMap = new Map()
30
+ const fetchedPricesMap = new Map()
31
+
32
+ const tickerSymbols = Array.isArray(assetTickers) ? assetTickers : [assetTickers]
33
+
34
+ const instructions = await Promise.all(
35
+ tickerSymbols.map((tickerSymbol) =>
36
+ prepareFetchInstructions({
37
+ tickerSymbol,
38
+ fiatTicker,
39
+ granularity,
40
+ getCacheFromStorage,
41
+ requestLimit,
42
+ specificTimestamp,
43
+ ignoreCache,
44
+ runtimeCache,
45
+ getRuntimeCacheKey,
46
+ requestTimestamp,
47
+ })
48
+ )
49
+ )
50
+
51
+ const assetTickersToFetch = []
52
+ instructions.forEach(({ history, limit, lastCachedItem, tickerSymbol }) => {
53
+ historicalPricesMap.set(tickerSymbol, history)
54
+ if (limit) {
55
+ assetTickersToFetch.push({ tickerSymbol, limit, lastCachedItem })
56
+ }
57
+ })
58
+
59
+ await fetchAndProcessTickers({
60
+ assetTickersToFetch,
61
+ api,
62
+ fiatTicker,
63
+ granularity,
64
+ timestamp,
65
+ ignoreInvalidSymbols,
66
+ historicalPricesMap,
67
+ fetchedPricesMap,
68
+ specificTimestamp,
69
+ requestLimit,
70
+ })
71
+
72
+ for (const tickerSymbol of tickerSymbols) {
73
+ const runtimeCacheKey = getRuntimeCacheKey({
74
+ fiatTicker,
75
+ assetTicker: tickerSymbol,
76
+ granularity,
77
+ })
78
+ const history = historicalPricesMap.get(tickerSymbol)
79
+ if (history && history.size > 0) {
80
+ runtimeCache.set(runtimeCacheKey, [...history])
81
+ }
82
+ }
83
+
84
+ return {
85
+ historicalPricesMap,
86
+ fetchedPricesMap,
87
+ }
88
+ }
@@ -0,0 +1,20 @@
1
+ const isValid = (value) => value !== 0 && Number.isFinite(value)
2
+
3
+ const findInvalidPrice = (data, lastCachedItem) =>
4
+ data.find((item, index) => {
5
+ if (!Number.isFinite(item.close) || !Number.isFinite(item.open)) return true
6
+
7
+ if (lastCachedItem && isValid(lastCachedItem.close) && index === 0) {
8
+ // check first item has value. It must, because we have cache for previous period.
9
+ return !isValid(item.close)
10
+ }
11
+
12
+ if (index > 0) {
13
+ const prevItem = data[index - 1]
14
+ // check if price is zero but previous wasn't
15
+ if (!isValid(item.close) && isValid(prevItem.close)) return true
16
+ if (!isValid(item.open) && isValid(prevItem.open)) return true
17
+ }
18
+ })
19
+
20
+ export default findInvalidPrice
@@ -0,0 +1,42 @@
1
+ import { timeToDays, timeToHours, timeToMinutes } from './utils.js'
2
+ import { LIMIT, START_TIME } from './constants.js'
3
+
4
+ import lastTimestampFromPricesMap from './last-timestamp-from-prices-map.js'
5
+
6
+ /**
7
+ * @typedef {import('./types.d.ts').GranularityType} GranularityType
8
+ * @typedef {import('./types.d.ts').HistoryType} HistoryType
9
+ */
10
+
11
+ /**
12
+ * @param {object} options
13
+ * @param {GranularityType} options.granularity
14
+ * @param {HistoryType} options.history
15
+ * @param {number} options.requestTimestamp
16
+ * @param {number} [options.requestLimit]
17
+ * @param {number} [options.specificTimestamp]
18
+ * @returns {number}
19
+ */
20
+ const getLimit = ({ granularity, history, requestLimit, requestTimestamp, specificTimestamp }) => {
21
+ if (specificTimestamp) return 1
22
+ // lastCachedTime is always UTC start of hour or day
23
+ const lastCachedTime = lastTimestampFromPricesMap(history)
24
+
25
+ const limitFromOptions = requestLimit || LIMIT[granularity]
26
+
27
+ if (granularity === 'minute') {
28
+ // don't fetch only if it's still same UTC minute
29
+ if (!lastCachedTime) return limitFromOptions
30
+ return Math.ceil(Math.min(limitFromOptions, timeToMinutes(requestTimestamp - lastCachedTime)))
31
+ }
32
+
33
+ if (granularity === 'hour') {
34
+ // don't fetch only if it's still same UTC hour
35
+ if (!lastCachedTime) return limitFromOptions
36
+ return Math.min(limitFromOptions, timeToHours(requestTimestamp - lastCachedTime))
37
+ }
38
+
39
+ return Math.min(limitFromOptions, timeToDays(requestTimestamp - (lastCachedTime || START_TIME)))
40
+ }
41
+
42
+ export default getLimit
package/module/index.js CHANGED
@@ -1,25 +1,16 @@
1
- import { mapValues } from '@exodus/basic-utils'
2
1
  import dayjs from '@exodus/dayjs'
3
- import { fetchHistoricalPrices } from '@exodus/price-api'
4
2
  import delay from 'delay'
5
3
  import makeConcurrent, { byArguments as makeConcurrentByArguments } from 'make-concurrent'
6
4
  import ms from 'ms'
7
5
  import { createInMemoryAtom, difference } from '@exodus/atoms'
8
6
  import { getAssetFromTicker, parseGranularity } from '../utils.js'
7
+ import fetchHistoricalPrices from './fetch-historical-prices.js'
9
8
 
10
9
  const CLEAR_MARKET_HISTORY_CACHE_KEY = 'clear-market-history-cache'
11
10
  const CLEAR_MARKET_HISTORY_CACHE_FROM_REMOTE_CONFIG_KEY =
12
11
  'clear-market-history-cache-from-remote-config'
13
12
  const MARKET_HISTORY_REFRESH_KEY = 'market-history-cache-refresh'
14
13
 
15
- const transformPriceEntries = (entries = []) => {
16
- const closePrices = entries.map((entry) => [entry[0], entry[1].close])
17
- return Object.fromEntries(closePrices)
18
- }
19
-
20
- const transformPricesByAssetName = (pricesByAssetName) =>
21
- mapValues(pricesByAssetName, (entries) => transformPriceEntries(entries))
22
-
23
14
  // when provided cache version from local or remote config is different from stored on device we clear the cache and fetch new prices
24
15
  const _invalidateStorageCache = async ({
25
16
  storage,
@@ -58,9 +49,37 @@ const getOldestTimestampFromCache = (cache) =>
58
49
  cache.length === 0 ? null : Math.min(...cache.map((item) => item[0]))
59
50
 
60
51
  const filterMapAssetDataFromServer = (data) =>
61
- data
62
- .filter((piece) => piece.close !== 0)
63
- .map((piece) => [piece.time * 1000, { close: piece.close }])
52
+ data.filter((item) => item.close !== 0).map((item) => [item.time * 1000, { close: item.close }])
53
+
54
+ const mapPricesForCache = (pricesMap, assetNames, assets) => {
55
+ const result = Object.create(null)
56
+ for (const assetName of assetNames) {
57
+ const assetPricesMap = pricesMap.get(assets[assetName].ticker)
58
+ result[assetName] = assetPricesMap ? [...assetPricesMap] : []
59
+ }
60
+
61
+ return result
62
+ }
63
+
64
+ const transformHistoricalPrices = (pricesMap, assetNames, assets) => {
65
+ const result = Object.create(null)
66
+ for (const assetName of assetNames) {
67
+ const assetPrices = pricesMap.get(assets[assetName].ticker)
68
+ if (assetPrices) {
69
+ const closePrices = Object.create(null)
70
+ // assetPrices is a Map, iterate it directly to avoid creating an intermediate array
71
+ for (const [time, priceData] of assetPrices.entries()) {
72
+ closePrices[time] = priceData.close
73
+ }
74
+
75
+ result[assetName] = closePrices
76
+ } else {
77
+ result[assetName] = Object.create(null)
78
+ }
79
+ }
80
+
81
+ return result
82
+ }
64
83
 
65
84
  const delayWithJitter = (ms, jitter = 0, signal) =>
66
85
  delay(Math.floor(ms + Math.random() * jitter), { signal })
@@ -229,29 +248,23 @@ class MarketHistoryMonitor {
229
248
  requestLimit: requestLimit || this.#granularityRequestLimits[granularity],
230
249
  })
231
250
 
232
- const mapPrices = (pricesMap) => {
233
- return Object.fromEntries(
234
- assetNames.map((assetName) => {
235
- const assetPricesMap = pricesMap.get(assets[assetName].ticker)
236
- const value = assetPricesMap ? [...assetPricesMap] : []
237
- return [assetName, value]
238
- })
239
- )
240
- }
241
-
242
- const pricesByAssetName = mapPrices(fetchedPricesMap)
243
-
244
- const fullHistoryByAssetName =
245
- fetchedPricesMap.size === historicalPricesMap.size
246
- ? pricesByAssetName
247
- : mapPrices(historicalPricesMap)
248
-
249
251
  if (fetchedPricesMap.size > 0) {
250
- await this.#setCache({ currency, granularity, pricesByAssetName })
252
+ const pricesByAssetNameForCache = mapPricesForCache(fetchedPricesMap, assetNames, assets)
253
+ await this.#setCache({
254
+ currency,
255
+ granularity,
256
+ pricesByAssetName: pricesByAssetNameForCache,
257
+ })
251
258
  }
252
259
 
260
+ const fullHistoryByAssetName = transformHistoricalPrices(
261
+ historicalPricesMap,
262
+ assetNames,
263
+ assets
264
+ )
265
+
253
266
  return {
254
- prices: transformPricesByAssetName(fullHistoryByAssetName),
267
+ prices: fullHistoryByAssetName,
255
268
  hasNewPrices: fetchedPricesMap.size > 0,
256
269
  }
257
270
  }
@@ -631,7 +644,9 @@ class MarketHistoryMonitor {
631
644
  })
632
645
 
633
646
  await this.#marketHistoryAtom.set(async (current) => {
634
- const transformedPrices = transformPriceEntries([...updatedAssetHistoryMap])
647
+ const transformedPrices = Object.fromEntries(
648
+ [...updatedAssetHistoryMap].map(([time, priceData]) => [time, priceData.close])
649
+ )
635
650
  const parsedGranularity = parseGranularity(granularity)
636
651
 
637
652
  return {
@@ -0,0 +1,4 @@
1
+ const lastTimestampFromPricesMap = (history) =>
2
+ history.size === 0 ? null : Math.max(...history.keys())
3
+
4
+ export default lastTimestampFromPricesMap
@@ -0,0 +1,48 @@
1
+ import getLimit from './get-limit-to-fetch.js'
2
+ import tryToGetCache from './try-to-load-cache.js'
3
+ import lastTimestampFromPricesMap from './last-timestamp-from-prices-map.js'
4
+
5
+ export default async function prepareFetchInstructions({
6
+ tickerSymbol,
7
+ fiatTicker,
8
+ granularity,
9
+ getCacheFromStorage,
10
+ requestLimit,
11
+ specificTimestamp,
12
+ ignoreCache,
13
+ runtimeCache,
14
+ getRuntimeCacheKey,
15
+ requestTimestamp,
16
+ }) {
17
+ const runtimeCacheKey = getRuntimeCacheKey({
18
+ fiatTicker,
19
+ assetTicker: tickerSymbol,
20
+ granularity,
21
+ })
22
+ const history = await tryToGetCache({
23
+ runtimeCacheKey,
24
+ runtimeCache,
25
+ getCacheFromStorage: () => getCacheFromStorage(tickerSymbol),
26
+ })
27
+
28
+ if (specificTimestamp && history.get(specificTimestamp)) {
29
+ return { history }
30
+ }
31
+
32
+ const limit = getLimit({
33
+ granularity,
34
+ history: ignoreCache ? new Map() : history,
35
+ requestLimit,
36
+ requestTimestamp,
37
+ specificTimestamp,
38
+ })
39
+
40
+ if (limit <= 0) {
41
+ return { history }
42
+ }
43
+
44
+ const lastCachedTime = lastTimestampFromPricesMap(history)
45
+ const lastCachedItem = history.get(lastCachedTime)
46
+
47
+ return { history, limit, lastCachedItem, tickerSymbol }
48
+ }
@@ -0,0 +1,56 @@
1
+ import findInvalidPrice from './find-invalid-price.js'
2
+ import cropHistory from './crop-history.js'
3
+
4
+ export default function processApiResponse({
5
+ assetTicker,
6
+ fiatTicker,
7
+ fetchedPrices,
8
+ assetTickersToFetch,
9
+ ignoreInvalidSymbols,
10
+ historicalPricesMap,
11
+ granularity,
12
+ specificTimestamp,
13
+ requestLimit,
14
+ }) {
15
+ if (assetTicker === 'requestErrors') {
16
+ const errors = fetchedPrices[assetTicker]
17
+ Object.keys(errors).forEach((key) => {
18
+ if (key !== 'invalidCryptoSymbols' || !ignoreInvalidSymbols) {
19
+ console.warn(`pricing-server: ${key}: `, errors[key])
20
+ }
21
+ })
22
+ return null
23
+ }
24
+
25
+ const newData = fetchedPrices[assetTicker][fiatTicker]
26
+ const { lastCachedItem } =
27
+ assetTickersToFetch.find(({ tickerSymbol }) => tickerSymbol === assetTicker) ||
28
+ Object.create(null)
29
+ const wrongDataItem = findInvalidPrice(newData, lastCachedItem)
30
+
31
+ if (wrongDataItem) {
32
+ const date = new Date(wrongDataItem.time * 1000)
33
+ console.warn(
34
+ `pricing-server: invalid ${assetTicker} price for ${date.toISOString()}: ${JSON.stringify(
35
+ wrongDataItem
36
+ )}`
37
+ )
38
+ return null
39
+ }
40
+
41
+ const filteredData = newData
42
+ .filter((piece) => piece.close !== 0)
43
+ .map((piece) => ({ time: piece.time, close: piece.close }))
44
+
45
+ let history = historicalPricesMap.get(assetTicker) || new Map()
46
+
47
+ for (const price of filteredData) {
48
+ history.set(price.time * 1000, { close: price.close })
49
+ }
50
+
51
+ if (granularity === 'hour' && !specificTimestamp && filteredData.length > 0 && requestLimit) {
52
+ history = cropHistory({ history, hourlyLimit: requestLimit })
53
+ }
54
+
55
+ return history
56
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @typedef {import('./types.js').RuntimeCacheType} RuntimeCacheType
3
+ * @typedef {import('./types.js').HistoryType} HistoryType
4
+ * @typedef {import('./types.js').GetCacheFromStorageResponseType} GetCacheFromStorageResponseType
5
+ */
6
+
7
+ /**
8
+ * @param {object} options
9
+ * @param {string} options.runtimeCacheKey
10
+ * @param {RuntimeCacheType} options.runtimeCache
11
+ * @param {() => Promise<GetCacheFromStorageResponseType>} options.getCacheFromStorage
12
+ * @returns {Promise<HistoryType>}
13
+ */
14
+
15
+ const tryToGetCache = async ({ runtimeCacheKey, runtimeCache, getCacheFromStorage }) => {
16
+ const cache = runtimeCache.get(runtimeCacheKey)
17
+
18
+ if (cache) {
19
+ return new Map(cache)
20
+ }
21
+
22
+ if (!getCacheFromStorage) {
23
+ return new Map()
24
+ }
25
+
26
+ try {
27
+ let cacheFromStorage = await getCacheFromStorage()
28
+
29
+ if (typeof cacheFromStorage === 'string') {
30
+ // backwards compatibility
31
+ const fixedCache = JSON.parse(cacheFromStorage)
32
+ cacheFromStorage = fixedCache
33
+ }
34
+
35
+ return new Map(cacheFromStorage)
36
+ } catch (e) {
37
+ throw new Error('failed to load cache from storage. ' + e)
38
+ }
39
+ }
40
+
41
+ export default tryToGetCache
@@ -0,0 +1,5 @@
1
+ import ms from 'ms'
2
+
3
+ export const timeToDays = (time) => time / ms('1d')
4
+ export const timeToHours = (time) => time / ms('1h')
5
+ export const timeToMinutes = (time) => time / ms('1m')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/market-history",
3
- "version": "10.2.3",
3
+ "version": "10.3.0",
4
4
  "description": "Fetches historical prices for assets",
5
5
  "author": "Exodus Movement, Inc.",
6
6
  "license": "MIT",
@@ -27,7 +27,6 @@
27
27
  "@exodus/atoms": "^9.0.0",
28
28
  "@exodus/basic-utils": "^3.0.1",
29
29
  "@exodus/dayjs": "^1.0.2",
30
- "@exodus/price-api": "^4.1.1",
31
30
  "@exodus/remote-config-atoms": "^1.1.0",
32
31
  "delay": "^5.0.0",
33
32
  "make-concurrent": "^5.4.0",
@@ -59,5 +58,5 @@
59
58
  "publishConfig": {
60
59
  "access": "public"
61
60
  },
62
- "gitHead": "a75b31501250fd4a5001ee29eaf929c4dead9c44"
61
+ "gitHead": "891258ec0890e3af85055d95b59be88d69b79de6"
63
62
  }