From a3f52960111ee9d1407947d2ee773180f6577d6d Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Mon, 31 Aug 2026 13:54:21 +0800 Subject: [PATCH] Bugfix/handle missing exchange rates to prevent infinite loading Guard indirect rate calculations against missing or non-finite data and return a finite fallback so portfolio snapshots can complete instead of hanging. Fixes #4299 --- .../exchange-rate-data.regression.spec.ts | 82 +++++++++++++++++++ .../exchange-rate-data.service.ts | 44 +++++++--- 2 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/services/exchange-rate-data/exchange-rate-data.regression.spec.ts diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.regression.spec.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.regression.spec.ts new file mode 100644 index 000000000..b86e7a4ab --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.regression.spec.ts @@ -0,0 +1,82 @@ +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +describe('ExchangeRateDataService regression #4299', () => { + let service: ExchangeRateDataService; + let mockDataProviderService: any; + let mockMarketDataService: any; + let mockPrismaService: any; + let mockPropertyService: any; + + beforeEach(() => { + mockDataProviderService = { + getDataSourceForExchangeRates: jest.fn().mockReturnValue('YAHOO'), + getHistorical: jest.fn().mockResolvedValue({}), + getQuotes: jest.fn().mockResolvedValue({}) + }; + mockMarketDataService = { + get: jest.fn().mockResolvedValue(null), + getRange: jest.fn().mockResolvedValue([]) + }; + mockPrismaService = { + account: { findMany: jest.fn().mockResolvedValue([]) }, + symbolProfile: { findMany: jest.fn().mockResolvedValue([]) } + }; + mockPropertyService = { + getByKey: jest.fn().mockResolvedValue([]) + }; + + service = new ExchangeRateDataService( + mockDataProviderService, + mockMarketDataService, + mockPrismaService, + mockPropertyService + ); + + // Ensure empty exchange rates to trigger missing-rate path + (service as any).exchangeRates = {}; + (service as any).derivedCurrencyFactors = {}; + }); + + it('toCurrency returns finite value when exchange rate is missing (CAD->USD)', () => { + const result = service.toCurrency(100, 'CAD', 'USD'); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(100); + expect(result).not.toBeNaN(); + }); + + it('toCurrency handles missing indirect rates without NaN', () => { + (service as any).exchangeRates = { + 'USDEUR': 0.9 + // CADUSD missing, CAD->USD via USD should be missing + }; + const result = service.toCurrency(100, 'CAD', 'USD'); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(100); + }); + + it('toCurrencyAtDate returns finite fallback when market data missing', async () => { + const pastDate = new Date('2025-01-15'); + const result = await service.toCurrencyAtDate(100, 'CAD', 'USD', pastDate); + expect(Number.isFinite(result as number)).toBe(true); + expect(result).toBe(100); + expect(result).not.toBeUndefined(); + }); + + it('toCurrencyAtDate returns finite for EUR->CAD missing rate', async () => { + const pastDate = new Date('2025-02-01'); + mockMarketDataService.get.mockResolvedValue(null); + const result = await service.toCurrencyAtDate(250, 'EUR', 'CAD', pastDate); + expect(Number.isFinite(result as number)).toBe(true); + expect(result).toBe(250); + }); + + it('toCurrencyAtDate returns 0 for zero amount even with missing rate', async () => { + const result = await service.toCurrencyAtDate(0, 'CAD', 'USD', new Date('2025-01-01')); + expect(result).toBe(0); + }); + + it('toCurrency returns 0 for zero amount even with missing rate', () => { + const result = service.toCurrency(0, 'CAD', 'USD'); + expect(result).toBe(0); + }); +}); 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 0d7e98e2e..e8741be70 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 @@ -234,17 +234,27 @@ export class ExchangeRateDataService { if (!this.exchangeRates[symbol]) { // Not found, calculate indirectly via base currency - this.exchangeRates[symbol] = + const price1 = resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[ dateStringOfYesterday - ]?.marketPrice * + ]?.marketPrice; + const price2 = resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[ dateStringOfYesterday ]?.marketPrice; - // Calculate the opposite direction - this.exchangeRates[`${currency2}${currency1}`] = - 1 / this.exchangeRates[symbol]; + if (isNumber(price1) && isNumber(price2)) { + this.exchangeRates[symbol] = price1 * price2; + + if ( + isNumber(this.exchangeRates[symbol]) && + Number.isFinite(this.exchangeRates[symbol]) && + this.exchangeRates[symbol] !== 0 + ) { + this.exchangeRates[`${currency2}${currency1}`] = + 1 / this.exchangeRates[symbol]; + } + } } } } @@ -271,11 +281,13 @@ export class ExchangeRateDataService { this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`]; const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; - factor = factor1 * factor2; + if (isNumber(factor1) && isNumber(factor2)) { + factor = factor1 * factor2; + } } } - if (isNumber(factor) && !isNaN(factor)) { + if (isNumber(factor) && Number.isFinite(factor) && !isNaN(factor)) { return factor * aValue; } @@ -357,13 +369,21 @@ export class ExchangeRateDataService { } catch {} // Calculate the opposite direction - factor = - (1 / marketPriceBaseCurrencyFromCurrency) * - marketPriceBaseCurrencyToCurrency; + if ( + isNumber(marketPriceBaseCurrencyFromCurrency) && + isNumber(marketPriceBaseCurrencyToCurrency) && + Number.isFinite(marketPriceBaseCurrencyFromCurrency) && + Number.isFinite(marketPriceBaseCurrencyToCurrency) && + marketPriceBaseCurrencyFromCurrency !== 0 + ) { + factor = + (1 / marketPriceBaseCurrencyFromCurrency) * + marketPriceBaseCurrencyToCurrency; + } } } - if (isNumber(factor) && !isNaN(factor)) { + if (isNumber(factor) && Number.isFinite(factor) && !isNaN(factor)) { return factor * aValue; } @@ -374,7 +394,7 @@ export class ExchangeRateDataService { )}` ); - return undefined; + return aValue; } private async getExchangeRates({