Browse Source

Exclude cash in base currency from portfolio performance calculation

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

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

@ -176,7 +176,7 @@ export abstract class PortfolioCalculator {
} }
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance(
positions: TimelinePosition[] positions: (TimelinePosition & { includeInPerformance: boolean })[]
): PortfolioSnapshot; ): PortfolioSnapshot;
@LogPerformance @LogPerformance
@ -314,6 +314,7 @@ export abstract class PortfolioCalculator {
const positions: (TimelinePosition & { const positions: (TimelinePosition & {
includeInHoldings: boolean; includeInHoldings: boolean;
includeInPerformance: boolean;
})[] = []; })[] = [];
const accumulatedValuesByDate: { const accumulatedValuesByDate: {
@ -356,6 +357,10 @@ export abstract class PortfolioCalculator {
const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity); const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity);
const isCashInBaseCurrency =
item.assetSubClass === AssetSubClass.CASH &&
item.currency === this.currency;
const { const {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
@ -396,17 +401,31 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
valuesBySymbol[item.symbol] = { // Cash in the base currency cannot generate a currency effect and thus
currentValues, // contributes nothing but its balance to the performance calculation
currentValuesWithCurrencyEffect, valuesBySymbol[item.symbol] = isCashInBaseCurrency
investmentValuesAccumulated, ? {
investmentValuesAccumulatedWithCurrencyEffect, currentValues,
investmentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
netPerformanceValues, investmentValuesAccumulated: {},
netPerformanceValuesWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect: {},
timeWeightedInvestmentValues, investmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentValuesWithCurrencyEffect netPerformanceValues: {},
}; netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {}
}
: {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
positions.push({ positions.push({
timeWeightedInvestment, timeWeightedInvestment,
@ -431,6 +450,7 @@ export abstract class PortfolioCalculator {
? (grossPerformanceWithCurrencyEffect ?? null) ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
includeInHoldings: item.includeInHoldings, includeInHoldings: item.includeInHoldings,
includeInPerformance: !isCashInBaseCurrency,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice: marketPrice:
@ -619,7 +639,7 @@ export abstract class PortfolioCalculator {
return includeInHoldings; return includeInHoldings;
}) })
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ includeInHoldings, ...rest }) => { .map(({ includeInHoldings, includeInPerformance, ...rest }) => {
return rest; return rest;
}); });

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

@ -314,5 +314,139 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 1820 valueWithCurrencyEffect: 1820
}); });
}); });
it('should exclude cash in the base currency from the performance calculation', 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: 1000
},
{
accountId,
date: parseDate('2024-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: [],
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 position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'CHF';
});
/**
* The holding itself keeps its investment and value so that it remains
* visible in the holdings table
*/
expect(position).toMatchObject({
currency: 'CHF',
grossPerformance: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(2000),
investmentWithCurrencyEffect: new Big(2000),
netPerformance: new Big(0),
quantity: new Big(2000),
symbol: 'CHF',
valueInBaseCurrency: new Big(2000)
});
/**
* Total investment: 0 CHF (cash in the base currency cannot generate a
* currency effect and would only dilute the performance)
* Current value in base currency: 2000 CHF (the cash still counts
* towards the net worth)
*/
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(2000),
hasErrors: false,
totalCashInBaseCurrency: new Big(2000),
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
});
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({
date: '2025-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 2000,
totalCashInBaseCurrency: 2000,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 2000,
valueWithCurrencyEffect: 2000
});
});
}); });
}); });

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

@ -26,7 +26,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[]; private chartDates: string[];
protected calculateOverallPerformance( protected calculateOverallPerformance(
positions: TimelinePosition[] positions: (TimelinePosition & { includeInPerformance: boolean })[]
): PortfolioSnapshot { ): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0); let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
@ -55,6 +55,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
hasErrors = true; hasErrors = true;
} }
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.investment) { if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment); totalInvestment = totalInvestment.plus(currentPosition.investment);

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

@ -209,6 +209,7 @@ export class PortfolioController {
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect', 'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalCashInBaseCurrency',
'totalInvestment', 'totalInvestment',
'totalInvestmentValueWithCurrencyEffect', 'totalInvestmentValueWithCurrencyEffect',
'totalSell', 'totalSell',

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

@ -2040,6 +2040,7 @@ export class PortfolioService {
.toNumber(), .toNumber(),
interestInBaseCurrency: interest.toNumber(), interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(),
totalCashInBaseCurrency: totalCashInBaseCurrency?.toNumber() ?? 0,
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect:
totalInvestmentWithCurrencyEffect.toNumber(), totalInvestmentWithCurrencyEffect.toNumber(),

72
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -164,7 +164,47 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1"> <div class="flex-grow-1 ml-3 text-truncate" i18n>Cash</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalCashInBaseCurrency"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 ml-4">
<ng-container i18n>Buying Power</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value
class="d-lg-inline-block d-none ml-2 small text-muted"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : buyingPowerPercentage"
/>
}
</div>
<div class="align-items-center d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 ml-3">
<ng-container i18n>Emergency Fund</ng-container> <ng-container i18n>Emergency Fund</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
@ -215,7 +255,7 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Cash</div> <div class="flex-grow-1 ml-4 text-truncate" i18n>in Cash</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -229,7 +269,7 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Assets</div> <div class="flex-grow-1 ml-4 text-truncate" i18n>in Holdings</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -242,32 +282,6 @@
/> />
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1">
<ng-container i18n>Buying Power</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value
class="d-lg-inline-block d-none ml-2 small text-muted"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : buyingPowerPercentage"
/>
}
</div>
<div class="align-items-center d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1"> <div class="align-items-center d-flex flex-grow-1">
<ng-container i18n>Excluded from Analysis</ng-container> <ng-container i18n>Excluded from Analysis</ng-container>

1
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -23,6 +23,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
interestInBaseCurrency: number; interestInBaseCurrency: number;
liabilitiesInBaseCurrency: number; liabilitiesInBaseCurrency: number;
totalBuy: number; totalBuy: number;
totalCashInBaseCurrency: number;
totalSell: number; totalSell: number;
totalValueInBaseCurrency?: number; totalValueInBaseCurrency?: number;
} }

Loading…
Cancel
Save