From 2c5f4fdca2d9e7204ac8cf0aed38586d797d6191 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:43:34 +0530 Subject: [PATCH] test(portfolio): cover a missing exchange rate in the calculator Builds the BALN.SW buy with both fee fields unset, which is the state the calculator sees when no exchange rate is available for the activity's date. Asserts the snapshot is still produced and the fees count as 0. Fails with "[big.js] Invalid number" without the preceding fix. --- ...tor-baln-buy-without-exchange-rate.spec.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts new file mode 100644 index 000000000..e1d44a078 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts @@ -0,0 +1,141 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and a missing exchange rate', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + // ExchangeRateDataService.toCurrencyAtDate() resolves to undefined when + // no exchange rate is available for the activity’s date, which leaves the + // converted fee fields undefined by the time they reach the calculator. + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: undefined, + feeInBaseCurrency: undefined, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // The snapshot is still computed; the unconvertible fees count as 0 + // rather than aborting the request with “[big.js] Invalid number”. + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + totalFeesWithCurrencyEffect: new Big('0'), + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-30', + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + investment: new Big('273.2'), + quantity: new Big('2'), + symbol: 'BALN.SW', + valueInBaseCurrency: new Big('297.8') + } + ] + }); + }); + }); +});