From 39b19abd4b7cb4cb6e7072b997453ad11fa3ffd0 Mon Sep 17 00:00:00 2001 From: Andrea Bugeja <25248102+arogan178@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:31 +0200 Subject: [PATCH] Cache market data of currency pairs in toCurrencyAtDate() --- CHANGELOG.md | 6 ++ .../exchange-rate-data.service.spec.ts | 93 ++++++++++++++++++ .../exchange-rate-data.service.ts | 98 +++++++++++++++---- 3 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f13393d1a..9e799f49d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Improved the performance of the historical currency conversion by loading the market data of each currency pair once per minute instead of once per date + ## 3.90.0 - 2026-10-21 ### Changed diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts new file mode 100644 index 0000000000..c80edc1884 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts @@ -0,0 +1,93 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { DataSource } from '@prisma/client'; +import { eachDayOfInterval } from 'date-fns'; +import ms from 'ms'; + +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +describe('ExchangeRateDataService', () => { + let exchangeRateDataService: ExchangeRateDataService; + let getRange: jest.Mock; + + beforeEach(() => { + getRange = jest.fn().mockResolvedValue([ + { date: parseDate('2026-01-02'), marketPrice: 0.9 }, + { date: parseDate('2026-01-05'), marketPrice: 0.8 } + ]); + + exchangeRateDataService = new ExchangeRateDataService( + { + getDataSourceForExchangeRates: () => { + return DataSource.YAHOO; + } + } as unknown as DataProviderService, + { getRange } as unknown as MarketDataService, + null, + null + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('toCurrencyAtDate', () => { + it('loads the market data of a currency pair once for all dates', async () => { + const dates = eachDayOfInterval({ + end: parseDate('2026-01-05'), + start: parseDate('2026-01-02') + }); + + await Promise.all( + dates.map((date) => { + return exchangeRateDataService.toCurrencyAtDate( + 100, + 'USD', + 'EUR', + date + ); + }) + ); + + expect(getRange).toHaveBeenCalledTimes(1); + }); + + it('converts with the market price of the date', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate( + 100, + 'USD', + 'EUR', + parseDate('2026-01-05') + ) + ).resolves.toBeCloseTo(80); + }); + + it('returns undefined for a date without market data', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate( + 100, + 'USD', + 'EUR', + parseDate('2026-01-03') + ) + ).resolves.toBeUndefined(); + }); + + it('reloads the market data after a minute', async () => { + const now = Date.now(); + const date = parseDate('2026-01-02'); + + await exchangeRateDataService.toCurrencyAtDate(100, 'USD', 'EUR', date); + + jest.spyOn(Date, 'now').mockReturnValue(now + ms('2 minutes')); + + await exchangeRateDataService.toCurrencyAtDate(100, 'USD', 'EUR', date); + + expect(getRange).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index b68ed2fb15..32b680da3b 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -39,6 +39,10 @@ export class ExchangeRateDataService { private currencyPairs: DataGatheringItem[] = []; private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {}; + private marketPricesBySymbol = new Map< + string, + { expiresAt: number; marketPrices: Promise } + >(); public constructor( private readonly dataProviderService: DataProviderService, @@ -309,18 +313,13 @@ export class ExchangeRateDataService { } else if (derivedCurrencyFactor) { factor = derivedCurrencyFactor; } else { - const dataSource = - this.dataProviderService.getDataSourceForExchangeRates(); - const symbol = `${aFromCurrency}${aToCurrency}`; - - const marketData = await this.marketDataService.get({ - dataSource, - symbol, - date: aDate + const marketPrice = await this.getMarketPriceAtDate({ + date: aDate, + symbol: `${aFromCurrency}${aToCurrency}` }); - if (marketData?.marketPrice) { - factor = marketData?.marketPrice; + if (marketPrice) { + factor = marketPrice; } else { // Calculate indirectly via base currency @@ -331,13 +330,11 @@ export class ExchangeRateDataService { if (aFromCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyFromCurrency = 1; } else { - marketPriceBaseCurrencyFromCurrency = ( - await this.marketDataService.get({ - dataSource, + marketPriceBaseCurrencyFromCurrency = + await this.getMarketPriceAtDate({ date: aDate, symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` - }) - )?.marketPrice; + }); } } catch {} @@ -345,13 +342,12 @@ export class ExchangeRateDataService { if (aToCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyToCurrency = 1; } else { - marketPriceBaseCurrencyToCurrency = ( - await this.marketDataService.get({ - dataSource, + marketPriceBaseCurrencyToCurrency = await this.getMarketPriceAtDate( + { date: aDate, symbol: `${DEFAULT_CURRENCY}${aToCurrency}` - }) - )?.marketPrice; + } + ); } } catch {} @@ -515,6 +511,68 @@ export class ExchangeRateDataService { return factors; } + /** + * Looks up the market price of a currency pair in its full history, which is + * loaded once per minute instead of querying the database for every date + */ + private async getMarketPriceAtDate({ + date, + symbol + }: { + date: Date; + symbol: string; + }) { + let item = this.marketPricesBySymbol.get(symbol); + + if (!item || item.expiresAt < Date.now()) { + item = { + expiresAt: Date.now() + ms('1 minute'), + marketPrices: this.loadMarketPrices(symbol) + }; + + this.marketPricesBySymbol.set(symbol, item); + + item.marketPrices.catch(() => { + if (this.marketPricesBySymbol.get(symbol) === item) { + this.marketPricesBySymbol.delete(symbol); + } + }); + } + + const marketPrices = await item.marketPrices; + + // Days without market data are 0 or out of bounds + return marketPrices[this.getUtcDaysSinceEpoch(date)] || undefined; + } + + private getUtcDaysSinceEpoch(aDate: Date) { + return Math.floor(aDate.getTime() / ms('1 day')); + } + + private async loadMarketPrices(aSymbol: string) { + const marketData = await this.marketDataService.getRange({ + assetProfileIdentifiers: [ + { + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + symbol: aSymbol + } + ], + dateQuery: { lt: new Date() } + }); + + const marketPrices = new Float64Array( + marketData.length > 0 + ? this.getUtcDaysSinceEpoch(marketData.at(-1).date) + 1 + : 0 + ); + + for (const { date, marketPrice } of marketData) { + marketPrices[this.getUtcDaysSinceEpoch(date)] = marketPrice; + } + + return marketPrices; + } + private async prepareCurrencies(): Promise { let currencies: string[] = [DEFAULT_CURRENCY];