diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ab3f76703..1d3f50580 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -392,19 +392,21 @@ export abstract class PortfolioCalculator { const includeInTotalAssetValue = item.assetSubClass !== AssetSubClass.CASH; - if (includeInTotalAssetValue) { - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; - } + // Cash positions are included in the chart (value, net worth and + // performance) so that the home screen reflects the entire portfolio. + // They remain excluded from the overall performance aggregate (see + // includeInTotalAssetValue) which keeps the FIRE wealth asset-only. + valuesBySymbol[item.symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; positions.push({ includeInTotalAssetValue, @@ -610,9 +612,10 @@ export abstract class PortfolioCalculator { netPerformance: totalNetPerformanceValue.toNumber(), netPerformanceWithCurrencyEffect: totalNetPerformanceValueWithCurrencyEffect.toNumber(), - netWorth: totalCurrentValueWithCurrencyEffect - .plus(totalAccountBalanceWithCurrencyEffect) - .toNumber(), + // Cash is now reflected through the cash positions in + // totalCurrentValueWithCurrencyEffect, so the account balance must no + // longer be added here to avoid counting cash twice in the net worth. + netWorth: totalCurrentValueWithCurrencyEffect.toNumber(), totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), totalInvestment: totalInvestmentValue.toNumber(), totalInvestmentValueWithCurrencyEffect: diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 7774ee629..396124b11 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -287,5 +287,109 @@ describe('PortfolioCalculator', () => { totalLiabilitiesWithCurrencyEffect: new Big(0) }); }); + + it('should include cash in the net worth and performance without counting it twice', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + id: randomUUID(), + date: parseDate('2023-12-31'), + value: 1000, + valueInBaseCurrency: 850 + }, + { + accountId, + id: randomUUID(), + date: parseDate('2024-12-31'), + value: 2000, + valueInBaseCurrency: 1800 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [], + count: 0 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const lastDataItem = portfolioSnapshot.historicalData.at(-1); + + // The cash position is now reflected in the portfolio value (it used to + // be excluded, which left the value at 0 for a cash-only portfolio). + expect(lastDataItem.valueWithCurrencyEffect).toBeGreaterThan(0); + + // The account balance is still tracked … + expect(lastDataItem.totalAccountBalance).toBeGreaterThan(0); + + // … but it is no longer added on top of the cash position, so the net + // worth equals the portfolio value and cash is not counted twice. + expect(lastDataItem.netWorth).toBeCloseTo( + lastDataItem.valueWithCurrencyEffect + ); + + // Cash now contributes to the performance (here through the currency + // effect), so it is no longer flat at 0 %. + expect( + lastDataItem.netPerformanceInPercentageWithCurrencyEffect + ).not.toBe(0); + }); }); });