Browse Source

Include cash in portfolio performance

pull/7148/head
Thomas Kaul 3 weeks ago
parent
commit
b0bffe8222
  1. 35
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 104
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

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

@ -392,19 +392,21 @@ export abstract class PortfolioCalculator {
const includeInTotalAssetValue = const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH; item.assetSubClass !== AssetSubClass.CASH;
if (includeInTotalAssetValue) { // Cash positions are included in the chart (value, net worth and
valuesBySymbol[item.symbol] = { // performance) so that the home screen reflects the entire portfolio.
currentValues, // They remain excluded from the overall performance aggregate (see
currentValuesWithCurrencyEffect, // includeInTotalAssetValue) which keeps the FIRE wealth asset-only.
investmentValuesAccumulated, valuesBySymbol[item.symbol] = {
investmentValuesAccumulatedWithCurrencyEffect, currentValues,
investmentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
netPerformanceValues, investmentValuesAccumulated,
netPerformanceValuesWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect,
timeWeightedInvestmentValues, investmentValuesWithCurrencyEffect,
timeWeightedInvestmentValuesWithCurrencyEffect netPerformanceValues,
}; netPerformanceValuesWithCurrencyEffect,
} timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
positions.push({ positions.push({
includeInTotalAssetValue, includeInTotalAssetValue,
@ -610,9 +612,10 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect // Cash is now reflected through the cash positions in
.plus(totalAccountBalanceWithCurrencyEffect) // totalCurrentValueWithCurrencyEffect, so the account balance must no
.toNumber(), // longer be added here to avoid counting cash twice in the net worth.
netWorth: totalCurrentValueWithCurrencyEffect.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(), totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect:

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

@ -287,5 +287,109 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big(0) 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,
id: randomUUID(),
date: parseDate('2023-12-31'),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
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);
// 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);
});
}); });
}); });

Loading…
Cancel
Save