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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { import {
@ -319,6 +318,7 @@ export abstract class PortfolioCalculator {
totalCurrentValueWithCurrencyEffect: Big; totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big; totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big; totalInvestmentValueWithCurrencyEffect: Big;
totalLiabilitiesWithCurrencyEffect: Big;
totalNetPerformanceValue: Big; totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big; totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big; totalTimeWeightedInvestmentValue: Big;
@ -389,9 +389,6 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
valuesBySymbol[item.symbol] = { valuesBySymbol[item.symbol] = {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
@ -405,7 +402,6 @@ export abstract class PortfolioCalculator {
}; };
positions.push({ positions.push({
includeInTotalAssetValue,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
activitiesCount: item.activitiesCount, activitiesCount: item.activitiesCount,
@ -479,8 +475,28 @@ export abstract class PortfolioCalculator {
{} as { [date: string]: Big } {} 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 accountBalanceMap: { [date: string]: Big } = {};
const liabilitiesMap: { [date: string]: Big } = {};
let accumulatedLiabilities = new Big(0);
let lastKnownBalance = new Big(0); let lastKnownBalance = new Big(0);
for (const dateString of chartDates) { for (const dateString of chartDates) {
@ -492,6 +508,14 @@ export abstract class PortfolioCalculator {
// Add the most recent balance to the accountBalanceMap // Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance; accountBalanceMap[dateString] = lastKnownBalance;
if (liabilityItemsMap[dateString] !== undefined) {
accumulatedLiabilities = accumulatedLiabilities.plus(
liabilityItemsMap[dateString]
);
}
liabilitiesMap[dateString] = accumulatedLiabilities;
for (const symbol of Object.keys(valuesBySymbol)) { for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol]; const symbolValues = valuesBySymbol[symbol];
@ -550,6 +574,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect), ).add(investmentValueAccumulatedWithCurrencyEffect),
totalLiabilitiesWithCurrencyEffect: liabilitiesMap[dateString],
totalNetPerformanceValue: ( totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0) new Big(0)
@ -580,6 +605,7 @@ export abstract class PortfolioCalculator {
totalCurrentValueWithCurrencyEffect, totalCurrentValueWithCurrencyEffect,
totalInvestmentValue, totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalNetPerformanceValue, totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect, totalNetPerformanceValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue, totalTimeWeightedInvestmentValue,
@ -608,7 +634,9 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect.toNumber(), netWorth: totalCurrentValueWithCurrencyEffect
.minus(totalLiabilitiesWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(), totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect:
@ -770,11 +798,6 @@ export abstract class PortfolioCalculator {
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue 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', () => { 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()); jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID(); const accountId = randomUUID();
@ -217,7 +217,11 @@ describe('PortfolioCalculator', () => {
userId: userDummyData.id 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 }) => { const position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'USD'; return symbol === 'USD';
@ -246,7 +250,6 @@ describe('PortfolioCalculator', () => {
'0.08211603004634809014' '0.08211603004634809014'
), ),
grossPerformanceWithCurrencyEffect: new Big(70), grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820), investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750), investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1, marketPrice: 1,
@ -281,115 +284,40 @@ describe('PortfolioCalculator', () => {
}); });
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(1820),
hasErrors: false, hasErrors: false,
totalFeesWithCurrencyEffect: new Big(0), totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0) totalLiabilitiesWithCurrencyEffect: new Big(0)
}); });
}); });
it('should include cash in the net worth and performance without counting it twice', async () => { it('should include cash in the net worth and performance without counting it twice', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); const portfolioSnapshot = await computePortfolioSnapshot();
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 lastDataItem = portfolioSnapshot.historicalData.at(-1); 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). * Value with currency effect: 2000 USD * 0.91 = 1820 CHF
expect(lastDataItem.valueWithCurrencyEffect).toBeGreaterThan(0); * Net worth: 1820 CHF - 0 CHF (liabilities) = 1820 CHF
* Total account balance: 1800 CHF (balance in base currency on 2024-12-31)
// The account balance is still tracked ... * Net performance with currency effect: 70 CHF / 852.45 CHF 8.21 %
expect(lastDataItem.totalAccountBalance).toBeGreaterThan(0); */
expect(lastDataItem).toEqual({
// ... but it is no longer added on top of the cash position, so the net date: '2025-01-01',
// worth equals the portfolio value and cash is not counted twice. investmentValueWithCurrencyEffect: 0,
expect(lastDataItem.netWorth).toBeCloseTo( netPerformance: 0,
lastDataItem.valueWithCurrencyEffect netPerformanceInPercentage: 0,
); netPerformanceInPercentageWithCurrencyEffect: 0.08211603004634808,
netPerformanceWithCurrencyEffect: 70,
// Cash now contributes to the performance (here through the currency netWorth: 1820,
// effect), so it is no longer flat at 0 %. totalAccountBalance: 1800,
expect( totalInvestment: 1820,
lastDataItem.netPerformanceInPercentageWithCurrencyEffect totalInvestmentValueWithCurrencyEffect: 1750,
).not.toBe(0); 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( expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual(
new Big(3000) 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 totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions.filter( for (const currentPosition of positions) {
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) { if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency currentPosition.feeInBaseCurrency

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

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

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

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

Loading…
Cancel
Save