From 7c36fead800521cfe6fdf9c859fd5d64c41a883d Mon Sep 17 00:00:00 2001 From: martin Date: Sun, 7 Jun 2026 19:12:11 +0200 Subject: [PATCH] fix(api): carry nearest exchange rate into uncovered dates getExchangeRatesByCurrency already carries missing exchange rates forward when assembling its result, but getExchangeRates logs a "No exchange rate has been found" error for every uncovered date before that happens. The base-currency market data is gathered only from the first activity date onwards and skips non-trading days, so any weekend, holiday, or date just outside the gathered range produces a spurious error even though the value is ultimately carried anyway. Fill the base-currency price maps with the nearest known price (carry forward, then back-fill a leading gap) before computing the cross rate, so those dates resolve quietly. A pair with no data at all still has nothing to carry and is reported as before. Signed-off-by: martin --- .../exchange-rate-data.service.spec.ts | 126 ++++++++++++++++++ .../exchange-rate-data.service.ts | 50 +++++++ 2 files changed, 176 insertions(+) create mode 100644 apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts 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 000000000..3c8cc2cde --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts @@ -0,0 +1,126 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; + +import { eachDayOfInterval } from 'date-fns'; + +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +const startDate = new Date(Date.UTC(2022, 7, 9)); // 2022-08-09 +const endDate = new Date(Date.UTC(2022, 7, 18)); // 2022-08-18 + +// Build an ExchangeRateDataService whose market data has full USDEUR coverage and +// the given USDBRL coverage, and no direct BRLEUR series (forcing the indirect +// calculation that derives BRLEUR from USDBRL and USDEUR). +function createService( + usdBrlMarketData: { date: Date; marketPrice: number }[] +) { + const marketDataService = { + getRange: jest.fn(async ({ assetProfileIdentifiers }) => { + const { symbol } = assetProfileIdentifiers[0]; + + if (symbol === 'USDBRL') { + return usdBrlMarketData; + } + + if (symbol === 'USDEUR') { + return eachDayOfInterval({ end: endDate, start: startDate }).map( + (date) => { + return { date, marketPrice: 0.98 }; + } + ); + } + + return []; + }) + } as unknown as MarketDataService; + + const dataProviderService = { + getDataSourceForExchangeRates: () => 'YAHOO' + } as unknown as DataProviderService; + + return new ExchangeRateDataService( + dataProviderService, + marketDataService, + null, + null + ); +} + +async function getBrlEurRates( + exchangeRateDataService: ExchangeRateDataService +) { + const result = await exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: ['BRL'], + endDate, + startDate, + targetCurrency: 'EUR' + }); + + return result['BRLEUR']; +} + +describe('ExchangeRateDataService', () => { + describe('getExchangeRatesByCurrency (indirect calculation)', () => { + it('back-fills a leading gap (data gathered only from a later date)', async () => { + // USDBRL is gathered only from 2022-08-16, leaving 2022-08-09..2022-08-15 + // uncovered — the case that produced the reported error. + const exchangeRateDataService = createService([ + { date: new Date(Date.UTC(2022, 7, 16)), marketPrice: 5.0956 }, + { date: new Date(Date.UTC(2022, 7, 17)), marketPrice: 5.1417 }, + { date: new Date(Date.UTC(2022, 7, 18)), marketPrice: 5.165 } + ]); + + const errorSpy = jest + .spyOn((exchangeRateDataService as any).logger, 'error') + .mockImplementation(() => undefined); + + const rates = await getBrlEurRates(exchangeRateDataService); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(Object.values(rates).length).toBeGreaterThan(0); + expect(Object.values(rates).every((rate) => Number.isFinite(rate))).toBe( + true + ); + + errorSpy.mockRestore(); + }); + + it('carries forward across a trailing gap (latest dates not yet gathered)', async () => { + // USDBRL stops at 2022-08-11, leaving 2022-08-12..2022-08-18 uncovered. + const exchangeRateDataService = createService([ + { date: new Date(Date.UTC(2022, 7, 9)), marketPrice: 5.12 }, + { date: new Date(Date.UTC(2022, 7, 10)), marketPrice: 5.1 }, + { date: new Date(Date.UTC(2022, 7, 11)), marketPrice: 5.15 } + ]); + + const errorSpy = jest + .spyOn((exchangeRateDataService as any).logger, 'error') + .mockImplementation(() => undefined); + + const rates = await getBrlEurRates(exchangeRateDataService); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(Object.values(rates).every((rate) => Number.isFinite(rate))).toBe( + true + ); + + errorSpy.mockRestore(); + }); + + it('still reports an error when the pair has no data at all', async () => { + // Nothing to carry from — the genuine "please provide market data" case + // must keep logging, so the fix stays surgical. + const exchangeRateDataService = createService([]); + + const errorSpy = jest + .spyOn((exchangeRateDataService as any).logger, 'error') + .mockImplementation(() => undefined); + + await getBrlEurRates(exchangeRateDataService); + + expect(errorSpy).toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); + }); +}); 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 708bfa591..c0b9ec2dc 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 @@ -460,6 +460,22 @@ export class ExchangeRateDataService { } } catch {} + // The base-currency market data is gathered from the first activity date + // onwards and skips non-trading days, so it does not cover every date in + // the requested interval. Carry the nearest known price into those gaps + // (as getExchangeRatesByCurrency already does for the resulting factors) + // so a weekend or a date just outside the gathered range does not log a + // spurious "No exchange rate has been found" error. A pair with no data at + // all still has nothing to carry and is reported as before. + this.fillMissingMarketPrices({ + dates, + marketPricesByDateString: marketPriceBaseCurrencyFromCurrency + }); + this.fillMissingMarketPrices({ + dates, + marketPricesByDateString: marketPriceBaseCurrencyToCurrency + }); + for (const date of dates) { try { const factor = @@ -490,6 +506,40 @@ export class ExchangeRateDataService { return factors; } + private fillMissingMarketPrices({ + dates, + marketPricesByDateString + }: { + dates: Date[]; + marketPricesByDateString: { [dateString: string]: number }; + }) { + // Forward pass: carry the most recent known price across later gaps + let lastKnownMarketPrice: number; + + for (const date of dates) { + const dateString = format(date, DATE_FORMAT); + + if (isNumber(marketPricesByDateString[dateString])) { + lastKnownMarketPrice = marketPricesByDateString[dateString]; + } else if (isNumber(lastKnownMarketPrice)) { + marketPricesByDateString[dateString] = lastKnownMarketPrice; + } + } + + // Backward pass: back-fill a leading gap with the earliest known price + let nextKnownMarketPrice: number; + + for (let i = dates.length - 1; i >= 0; i--) { + const dateString = format(dates[i], DATE_FORMAT); + + if (isNumber(marketPricesByDateString[dateString])) { + nextKnownMarketPrice = marketPricesByDateString[dateString]; + } else if (isNumber(nextKnownMarketPrice)) { + marketPricesByDateString[dateString] = nextKnownMarketPrice; + } + } + } + private async prepareCurrencies(): Promise { let currencies: string[] = [DEFAULT_CURRENCY];