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 81c3191f99..182e3c5367 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 @@ -1,7 +1,11 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +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'; @@ -19,6 +23,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { DataSource } from '@prisma/client'; import { Big } from 'big.js'; +import { eachDayOfInterval } from 'date-fns'; import { randomUUID } from 'node:crypto'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { @@ -453,5 +458,148 @@ describe('PortfolioCalculator', () => { valueWithCurrencyEffect: 0 }); }); + + it('should add cash in the base currency to the net worth of a portfolio with holdings', 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: 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: [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + date: parseDate('2023-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 100 + } + ], + count: 1 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + // The cash symbol has no market data, the holding is quoted at a + // constant price so that it does not generate any performance on its own + jest + .spyOn(currentRateService, 'getValues') + .mockImplementation(({ dataGatheringItems, dateQuery }) => { + const values = []; + + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { + for (const { dataSource, symbol } of dataGatheringItems) { + if (symbol === 'NOVN.SW') { + values.push({ date, dataSource, marketPrice: 100, symbol }); + } + } + } + + return Promise.resolve({ + values, + dataProviderInfos: [], + errors: [] + }); + }); + + 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(); + + /** + * Total assets: 2000 CHF cash + 2 * 100 CHF holding = 2200 CHF + * Total investment: 200 CHF (only the holding, the cash is excluded) + */ + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(2200), + hasErrors: false, + totalCashInBaseCurrency: new Big(2000), + totalInvestment: new Big(200) + }); + + /** + * Value: 200 CHF (the holding only, the cash is excluded from the + * performance calculation) + * Net worth: 2200 CHF (the value plus the cash, counted exactly once) + */ + expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 2200, + totalCashInBaseCurrency: 2000, + totalInvestment: 200, + totalInvestmentValueWithCurrencyEffect: 200, + value: 200, + valueWithCurrencyEffect: 200 + }); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 3d9fac712f..5239de24f8 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -208,6 +208,7 @@ export class PortfolioController { 'liabilitiesInBaseCurrency', 'netPerformance', 'netPerformanceWithCurrencyEffect', + 'totalAssetsInBaseCurrency', 'totalBuy', 'totalCashInBaseCurrency', 'totalInvestment', diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6ae86e4e7b..2b8640d38e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1897,10 +1897,10 @@ export class PortfolioService { } const { - currentValueInBaseCurrency, totalCashInBaseCurrency, totalInvestment, - totalInvestmentWithCurrencyEffect + totalInvestmentWithCurrencyEffect, + currentValueInBaseCurrency: totalAssetsInBaseCurrency } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ @@ -1909,6 +1909,7 @@ export class PortfolioService { }); const { + currentValueInBaseCurrency, netPerformance, netPerformancePercentage, netPerformancePercentageWithCurrencyEffect, @@ -1975,7 +1976,7 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(currentValueInBaseCurrency) + const netWorth = new Big(totalAssetsInBaseCurrency) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); @@ -1999,6 +2000,7 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, + currentValueInBaseCurrency, dateOfFirstActivity, excludedAccountsAndActivities, netPerformance, @@ -2010,7 +2012,6 @@ export class PortfolioService { activityCount: activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundHoldingsValueInBaseCurrency, @@ -2026,7 +2027,7 @@ export class PortfolioService { : undefined, fireWealth: { today: { - valueInBaseCurrency: new Big(currentValueInBaseCurrency) + valueInBaseCurrency: new Big(totalAssetsInBaseCurrency) .minus(totalCashInBaseCurrency ?? 0) .minus(emergencyFundHoldingsValueInBaseCurrency) .toNumber() @@ -2040,6 +2041,7 @@ export class PortfolioService { .toNumber(), interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), + totalAssetsInBaseCurrency: totalAssetsInBaseCurrency.toNumber(), totalCashInBaseCurrency: balanceInBaseCurrency, totalInvestment: totalInvestment.toNumber(), totalInvestmentValueWithCurrencyEffect: 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 3c1d3e9ad1..1bf1292911 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 @@ -159,7 +159,7 @@ [locale]="locale" [precision]="precision" [unit]="baseCurrency" - [value]="isLoading ? undefined : summary?.currentValueInBaseCurrency" + [value]="isLoading ? undefined : summary?.totalAssetsInBaseCurrency" /> diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index 5b3c0f46b8..8bc79b6246 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -79,14 +79,14 @@ export class GfPortfolioSummaryComponent implements OnChanges { protected get holdingsInBaseCurrency() { if ( - !isNumber(this.summary?.currentValueInBaseCurrency) || + !isNumber(this.summary?.totalAssetsInBaseCurrency) || !isNumber(this.summary?.totalCashInBaseCurrency) ) { return null; } return ( - this.summary.currentValueInBaseCurrency - + this.summary.totalAssetsInBaseCurrency - this.summary.totalCashInBaseCurrency ); } diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 5d1e304791..e109a7e63f 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -22,6 +22,7 @@ export interface PortfolioSummary extends PortfolioPerformance { grossPerformanceWithCurrencyEffect: number; interestInBaseCurrency: number; liabilitiesInBaseCurrency: number; + totalAssetsInBaseCurrency: number; totalBuy: number; totalCashInBaseCurrency: number; totalSell: number;