Browse Source

Include cash in portfolio performance

pull/7148/head
Thomas Kaul 6 days ago
parent
commit
946593a2f0
  1. 45
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 130
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  3. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  4. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  5. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 2
      libs/common/src/lib/models/timeline-position.ts

45
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -39,7 +39,6 @@ import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
@ -319,6 +318,7 @@ export abstract class PortfolioCalculator {
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalLiabilitiesWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
@ -389,9 +389,6 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
@ -405,7 +402,6 @@ export abstract class PortfolioCalculator {
};
positions.push({
includeInTotalAssetValue,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
activitiesCount: item.activitiesCount,
@ -479,8 +475,28 @@ export abstract class PortfolioCalculator {
{} as { [date: string]: Big }
);
const liabilityItemsMap = this.activities.reduce(
(map, { assetProfile, date, quantity, type, unitPrice }) => {
if (type === 'LIABILITY') {
const exchangeRate =
exchangeRatesByCurrency[
`${assetProfile.currency}${this.currency}`
]?.[date] ?? 1;
map[date] = (map[date] ?? new Big(0)).plus(
quantity.mul(unitPrice).mul(exchangeRate)
);
}
return map;
},
{} as { [date: string]: Big }
);
const accountBalanceMap: { [date: string]: Big } = {};
const liabilitiesMap: { [date: string]: Big } = {};
let accumulatedLiabilities = new Big(0);
let lastKnownBalance = new Big(0);
for (const dateString of chartDates) {
@ -492,6 +508,14 @@ export abstract class PortfolioCalculator {
// Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance;
if (liabilityItemsMap[dateString] !== undefined) {
accumulatedLiabilities = accumulatedLiabilities.plus(
liabilityItemsMap[dateString]
);
}
liabilitiesMap[dateString] = accumulatedLiabilities;
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
@ -550,6 +574,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect),
totalLiabilitiesWithCurrencyEffect: liabilitiesMap[dateString],
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
@ -580,6 +605,7 @@ export abstract class PortfolioCalculator {
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue,
@ -608,7 +634,9 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect
.minus(totalLiabilitiesWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
@ -770,11 +798,6 @@ export abstract class PortfolioCalculator {
? 0
: netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue
// TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
// netWorth: 0
});
}
}

130
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -137,7 +137,7 @@ describe('PortfolioCalculator', () => {
});
describe('Cash Performance', () => {
it('should calculate performance for cash assets in CHF default currency', async () => {
const computePortfolioSnapshot = async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
@ -217,7 +217,11 @@ describe('PortfolioCalculator', () => {
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
return portfolioCalculator.computeSnapshot();
};
it('should calculate performance for cash assets in CHF default currency', async () => {
const portfolioSnapshot = await computePortfolioSnapshot();
const position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'USD';
@ -246,7 +250,6 @@ describe('PortfolioCalculator', () => {
'0.08211603004634809014'
),
grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1,
@ -281,115 +284,40 @@ describe('PortfolioCalculator', () => {
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(1820),
hasErrors: false,
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
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,
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
date: parseDate('2024-12-31'),
id: randomUUID(),
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 portfolioSnapshot = await computePortfolioSnapshot();
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);
/**
* Value with currency effect: 2000 USD * 0.91 = 1820 CHF
* Net worth: 1820 CHF - 0 CHF (liabilities) = 1820 CHF
* Total account balance: 1800 CHF (balance in base currency on 2024-12-31)
* Net performance with currency effect: 70 CHF / 852.45 CHF 8.21 %
*/
expect(lastDataItem).toEqual({
date: '2025-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.08211603004634808,
netPerformanceWithCurrencyEffect: 70,
netWorth: 1820,
totalAccountBalance: 1800,
totalInvestment: 1820,
totalInvestmentValueWithCurrencyEffect: 1750,
value: 1820,
valueWithCurrencyEffect: 1820
});
});
});
});

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -113,6 +113,14 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual(
new Big(3000)
);
/**
* Net worth: 0 USD (current value) - 3000 USD (liabilities) = -3000 USD
*/
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({
netWorth: -3000,
valueWithCurrencyEffect: 0
});
});
});
});

6
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -40,11 +40,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions.filter(
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency

9
apps/api/src/app/portfolio/portfolio.service.ts

@ -541,12 +541,6 @@ export class PortfolioService {
let filteredValueInBaseCurrency = currentValueInBaseCurrency;
if (!this.activitiesService.areCashActivitiesExcludedByFilters(filters)) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency
);
}
const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return {
dataSource,
@ -1969,8 +1963,7 @@ export class PortfolioService {
.plus(totalOfExcludedActivities)
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
const netWorth = new Big(currentValueInBaseCurrency)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();

2
libs/common/src/lib/models/timeline-position.ts

@ -51,8 +51,6 @@ export class TimelinePosition {
@Type(() => Big)
grossPerformanceWithCurrencyEffect: Big;
includeInTotalAssetValue?: boolean;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
investment: Big;

Loading…
Cancel
Save