From cdc103d60d59ae2da861c9c4dbe93449366de452 Mon Sep 17 00:00:00 2001 From: Andrea Bugeja <25248102+arogan178@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:20:41 +0200 Subject: [PATCH] Fix historical conversion of derived currencies via root currency --- CHANGELOG.md | 4 ++ .../exchange-rate-data.service.spec.ts | 69 +++++++++++++++++++ .../exchange-rate-data.service.ts | 29 ++++++-- 3 files changed, 98 insertions(+), 4 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 7e35621f0b..920937dec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs +### Fixed + +- Fixed the historical currency conversion of derived currencies (e.g. `GBp`) by converting via their root currency + ## 3.88.0 - 2026-10-17 ### Added 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..4e7e89f18f --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts @@ -0,0 +1,69 @@ +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 { ExchangeRateDataService } from './exchange-rate-data.service'; + +describe('ExchangeRateDataService', () => { + const date = parseDate('2026-01-02'); + let exchangeRateDataService: ExchangeRateDataService; + + beforeEach(() => { + // Only the root currency pairs exist, no derived pairs like USDGBp + const marketPrices: { [symbol: string]: number } = { + USDEUR: 0.9, + USDGBP: 0.8 + }; + + exchangeRateDataService = new ExchangeRateDataService( + { + getDataSourceForExchangeRates: () => { + return DataSource.YAHOO; + } + } as unknown as DataProviderService, + { + get: jest.fn(({ symbol }: { symbol: string }) => { + return Promise.resolve( + marketPrices[symbol] ? { marketPrice: marketPrices[symbol] } : null + ); + }) + } as unknown as MarketDataService, + null, + null + ); + }); + + describe('toCurrencyAtDate', () => { + it('converts a derived currency to its root currency', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate(1000, 'GBp', 'GBP', date) + ).resolves.toBeCloseTo(10); + }); + + it('converts a root currency to its derived currency', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate(10, 'GBP', 'GBp', date) + ).resolves.toBeCloseTo(1000); + }); + + it('converts a derived currency indirectly via the root currency', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate(8000, 'GBp', 'EUR', date) + ).resolves.toBeCloseTo(90); + }); + + it('converts to a derived currency indirectly via the root currency', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate(90, 'EUR', 'GBp', date) + ).resolves.toBeCloseTo(8000); + }); + + it('keeps the value of the same derived currency', async () => { + await expect( + exchangeRateDataService.toCurrencyAtDate(1.15, 'GBp', 'GBp', date) + ).resolves.toBe(1.15); + }); + }); +}); 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..c0b0751426 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 @@ -296,18 +296,39 @@ export class ExchangeRateDataService { return 0; } + const derivedFromCurrency = DERIVED_CURRENCIES.find(({ currency }) => { + return currency === aFromCurrency; + }); + const derivedToCurrency = DERIVED_CURRENCIES.find(({ currency }) => { + return currency === aToCurrency; + }); + + if ( + aFromCurrency !== aToCurrency && + (derivedFromCurrency || derivedToCurrency) + ) { + // Convert via the root currencies, since the exchange rates of derived + // currencies are not available for every currency pair + const valueInRootCurrency = await this.toCurrencyAtDate( + aValue / (derivedFromCurrency?.factor ?? 1), + derivedFromCurrency?.rootCurrency ?? aFromCurrency, + derivedToCurrency?.rootCurrency ?? aToCurrency, + aDate + ); + + return valueInRootCurrency === undefined + ? undefined + : valueInRootCurrency * (derivedToCurrency?.factor ?? 1); + } + if (isToday(aDate)) { return this.toCurrency(aValue, aFromCurrency, aToCurrency); } - const derivedCurrencyFactor = - this.derivedCurrencyFactors[`${aFromCurrency}${aToCurrency}`]; let factor: number; if (aFromCurrency === aToCurrency) { factor = 1; - } else if (derivedCurrencyFactor) { - factor = derivedCurrencyFactor; } else { const dataSource = this.dataProviderService.getDataSourceForExchangeRates();