From 1af33d9aa4f2592874d47d496e529ef858653786 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:55:52 +0200 Subject: [PATCH] Exclude cash in base currency from portfolio performance calculation --- .../calculator/portfolio-calculator.ts | 46 ++++-- .../roai/portfolio-calculator-cash.spec.ts | 134 ++++++++++++++++++ .../calculator/roai/portfolio-calculator.ts | 6 +- .../src/app/portfolio/portfolio.controller.ts | 1 + .../src/app/portfolio/portfolio.service.ts | 1 + .../portfolio-summary.component.html | 72 ++++++---- .../interfaces/portfolio-summary.interface.ts | 1 + 7 files changed, 218 insertions(+), 43 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 8f603fc867..12f5cada2c 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -176,7 +176,7 @@ export abstract class PortfolioCalculator { } protected abstract calculateOverallPerformance( - positions: TimelinePosition[] + positions: (TimelinePosition & { includeInPerformance: boolean })[] ): PortfolioSnapshot; @LogPerformance @@ -314,6 +314,7 @@ export abstract class PortfolioCalculator { const positions: (TimelinePosition & { includeInHoldings: boolean; + includeInPerformance: boolean; })[] = []; const accumulatedValuesByDate: { @@ -356,6 +357,10 @@ export abstract class PortfolioCalculator { const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity); + const isCashInBaseCurrency = + item.assetSubClass === AssetSubClass.CASH && + item.currency === this.currency; + const { currentValues, currentValuesWithCurrencyEffect, @@ -396,17 +401,31 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; + // Cash in the base currency cannot generate a currency effect and thus + // contributes nothing but its balance to the performance calculation + valuesBySymbol[item.symbol] = isCashInBaseCurrency + ? { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {} + } + : { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; positions.push({ timeWeightedInvestment, @@ -431,6 +450,7 @@ export abstract class PortfolioCalculator { ? (grossPerformanceWithCurrencyEffect ?? null) : null, includeInHoldings: item.includeInHoldings, + includeInPerformance: !isCashInBaseCurrency, investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, marketPrice: @@ -619,7 +639,7 @@ export abstract class PortfolioCalculator { return includeInHoldings; }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(({ includeInHoldings, ...rest }) => { + .map(({ includeInHoldings, includeInPerformance, ...rest }) => { return rest; }); 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 551189fccc..dc22f75965 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 @@ -314,5 +314,139 @@ describe('PortfolioCalculator', () => { valueWithCurrencyEffect: 1820 }); }); + + it('should exclude cash in the base currency from the performance calculation', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + date: parseDate('2023-12-31'), + id: randomUUID(), + value: 1000, + valueInBaseCurrency: 1000 + }, + { + accountId, + date: parseDate('2024-12-31'), + id: randomUUID(), + value: 2000, + valueInBaseCurrency: 2000 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'CHF', + id: accountId, + isExcluded: false, + name: 'CHF', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 2000 + }); + + 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 position = portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'CHF'; + }); + + /** + * The holding itself keeps its investment and value so that it remains + * visible in the holdings table + */ + expect(position).toMatchObject({ + currency: 'CHF', + grossPerformance: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(2000), + investmentWithCurrencyEffect: new Big(2000), + netPerformance: new Big(0), + quantity: new Big(2000), + symbol: 'CHF', + valueInBaseCurrency: new Big(2000) + }); + + /** + * Total investment: 0 CHF (cash in the base currency cannot generate a + * currency effect and would only dilute the performance) + * Current value in base currency: 2000 CHF (the cash still counts + * towards the net worth) + */ + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(2000), + hasErrors: false, + totalCashInBaseCurrency: new Big(2000), + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 2000, + totalCashInBaseCurrency: 2000, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 2000, + valueWithCurrencyEffect: 2000 + }); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 18a8f7cd85..7214f8b445 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -26,7 +26,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { private chartDates: string[]; protected calculateOverallPerformance( - positions: TimelinePosition[] + positions: (TimelinePosition & { includeInPerformance: boolean })[] ): PortfolioSnapshot { let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); @@ -55,6 +55,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { hasErrors = true; } + if (!currentPosition.includeInPerformance) { + continue; + } + if (currentPosition.investment) { totalInvestment = totalInvestment.plus(currentPosition.investment); diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 175532cadc..3d9fac712f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -209,6 +209,7 @@ export class PortfolioController { 'netPerformance', 'netPerformanceWithCurrencyEffect', 'totalBuy', + 'totalCashInBaseCurrency', 'totalInvestment', 'totalInvestmentValueWithCurrencyEffect', 'totalSell', diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6c527ed2b0..b7561605da 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2040,6 +2040,7 @@ export class PortfolioService { .toNumber(), interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), + totalCashInBaseCurrency: totalCashInBaseCurrency?.toNumber() ?? 0, totalInvestment: totalInvestment.toNumber(), totalInvestmentValueWithCurrencyEffect: totalInvestmentWithCurrencyEffect.toNumber(), diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index e144794256..70b087ea31 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -164,7 +164,47 @@
-
+
Cash
+
+ +
+
+
+
+ Buying Power + @if ( + !hasImpersonationId && + summary?.totalValueInBaseCurrency > 0 && + user?.settings?.isExperimentalFeatures + ) { + + } +
+
+ +
+
+
+
Emergency Fund @if ( !hasImpersonationId && @@ -215,7 +255,7 @@
-
Cash
+
in Cash
-
Assets
+
in Holdings
-
-
- Buying Power - @if ( - !hasImpersonationId && - summary?.totalValueInBaseCurrency > 0 && - user?.settings?.isExperimentalFeatures - ) { - - } -
-
- -
-
Excluded from Analysis diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 8db6b39bba..5d1e304791 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -23,6 +23,7 @@ export interface PortfolioSummary extends PortfolioPerformance { interestInBaseCurrency: number; liabilitiesInBaseCurrency: number; totalBuy: number; + totalCashInBaseCurrency: number; totalSell: number; totalValueInBaseCurrency?: number; }