diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash-performance.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash-performance.spec.ts new file mode 100644 index 000000000..2ebe33f25 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash-performance.spec.ts @@ -0,0 +1,254 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + 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, + ExportResponse, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { join } from 'node:path'; + +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 exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join(__dirname, '../../../../../../../test/import/ok/sample.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('Cash Performance', () => { + it('should calculate performance for cash assets in CHF default currency', async () => { + // Mock Date + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + // Override activities with synthetic cash orders + // Scenario from User: + // 2023-12-31T00:00:00.000Z: 1000 + // 2024-12-31T00:00:00.000Z: 2000 + + const accountId = exportResponse.accounts[0].id; // Use exportResponse to simulate tying to real account + + const syntheticActivities: Activity[] = [ + { + ...activityDummyData, + accountId, + comment: 'Synthetic Cash Start', + currency: 'USD', + date: parseDate('2023-12-31'), + feeInAssetProfileCurrency: 0, + quantity: 1000, + type: 'BUY', + SymbolProfile: { + ...symbolProfileDummyData, + assetSubClass: 'CASH', + currency: 'USD', + dataSource: 'MANUAL', + name: 'US Dollar', + symbol: 'USD' + }, + unitPriceInAssetProfileCurrency: 1 + }, + { + ...activityDummyData, + accountId, + comment: 'Synthetic Cash Increment', + currency: 'USD', + date: parseDate('2024-12-31'), + feeInAssetProfileCurrency: 0, + quantity: 1000, // +1000 to reach 2000 total + type: 'BUY', + SymbolProfile: { + ...symbolProfileDummyData, + assetSubClass: 'CASH', + currency: 'USD', + dataSource: 'MANUAL', + name: 'US Dollar', + symbol: 'USD' + }, + unitPriceInAssetProfileCurrency: 1 + } + ]; + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + USDCHF: { + '2023-12-31': 0.85, + '2024-01-01': 0.86, + '2024-12-31': 0.9, + '2025-01-01': 0.91 + } + }); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + values: [], + errors: [] + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: syntheticActivities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', // User default currency overridden to CHF + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalData20231231 = portfolioSnapshot.historicalData.find( + (d) => d.date === '2023-12-31' + ); + const historicalData20240101 = portfolioSnapshot.historicalData.find( + (d) => d.date === '2024-01-01' + ); + const historicalData20241231 = portfolioSnapshot.historicalData.find( + (d) => d.date === '2024-12-31' + ); + + /** + * Expected logic: + * Investment value with currency effect: 1000 USD * 0.85 = 850 CHF + * Total investment: 1000 USD * 0.91 = 910 CHF + * Value (current): 1000 USD * 0.91 = 910 CHF + * Value with currency effect: 1000 USD * 0.85 = 850 CHF + */ + expect(historicalData20231231).toMatchObject({ + date: '2023-12-31', + investmentValueWithCurrencyEffect: 850, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 850, + totalAccountBalance: 0, + totalInvestment: 910, + totalInvestmentValueWithCurrencyEffect: 850, + value: 910, + valueWithCurrencyEffect: 850 + }); + + /** + * Expected logic: + * Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF + * Total investment: 1000 USD * 0.91 = 910 CHF + * Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF + * Value (current): 1000 USD * 0.91 = 910 CHF + * Value with currency effect: 1000 USD * 0.86 = 860 CHF + */ + expect(historicalData20240101).toMatchObject({ + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941, + netPerformanceWithCurrencyEffect: 10, + netWorth: 860, + totalAccountBalance: 0, + totalInvestment: 910, + totalInvestmentValueWithCurrencyEffect: 850, + value: 910, + valueWithCurrencyEffect: 860 + }); + + /** + * Expected logic: + * Investment value with currency effect: 1000 USD * 0.90 = 900 CHF + * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF + * Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF + * Total investment: 2000 USD * 0.91 = 1820 CHF + * Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF + * Value (current): 2000 USD * 0.91 = 1820 CHF + * Value with currency effect: 2000 USD * 0.9 = 1800 CHF + */ + expect(historicalData20241231).toMatchObject({ + date: '2024-12-31', + investmentValueWithCurrencyEffect: 900, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705, + netPerformanceWithCurrencyEffect: 50, + netWorth: 1800, + totalAccountBalance: 0, + totalInvestment: 1820, + totalInvestmentValueWithCurrencyEffect: 1750, + value: 1820, + valueWithCurrencyEffect: 1800 + }); + }); + }); +});