Browse Source

Exclude cash in base currency from portfolio performance calculation

pull/7443/head
Thomas Kaul 1 month ago
parent
commit
5f76e54012
  1. 150
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  2. 1
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 2
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  5. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  6. 1
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts

150
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
});
});
});
});

1
apps/api/src/app/portfolio/portfolio.controller.ts

@ -208,6 +208,7 @@ export class PortfolioController {
'liabilitiesInBaseCurrency',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalAssetsInBaseCurrency',
'totalBuy',
'totalCashInBaseCurrency',
'totalInvestment',

12
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:

2
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"
/>
</div>
</div>

4
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
);
}

1
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;

Loading…
Cancel
Save