From 56bb984aa3e5719ff475f6636a9b1c73430e811b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:34:31 +0200 Subject: [PATCH] Task/exclude cash in base currency from portfolio performance calculation (#7443) * Exclude cash in base currency from portfolio performance calculation * Update changelog --- CHANGELOG.md | 4 + .../calculator/portfolio-calculator.ts | 72 +++-- .../roai/portfolio-calculator-cash.spec.ts | 289 +++++++++++++++++- .../calculator/roai/portfolio-calculator.ts | 21 +- ...portfolio-calculator-position.interface.ts | 6 + .../src/app/portfolio/portfolio.controller.ts | 2 + .../src/app/portfolio/portfolio.service.ts | 23 +- .../portfolio-summary.component.html | 174 +++++++---- .../portfolio-summary.component.scss | 8 + .../portfolio-summary.component.ts | 27 +- .../interfaces/portfolio-summary.interface.ts | 2 + 11 files changed, 527 insertions(+), 101 deletions(-) create mode 100644 apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a5607dc..01c8b2394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the portfolio summary by presenting the cash and the holdings as a breakdown of the total assets +- Improved the _FIRE_ calculator by including the cash which is not part of the emergency fund +- Improved the performance calculation and the value of the portfolio by excluding cash denominated in the base currency +- Extended the portfolio details endpoint to include the total assets and the total cash in the portfolio summary - Deprecated `firstOrderDate` in favor of `dateOfFirstActivity` in the `GET api/v2/portfolio/performance` endpoint - Improved the log output in the get asset profile functionality of the _Financial Modeling Prep_ service for delisted asset profiles - Refreshed the cryptocurrencies list diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 8f603fc86..80968ebd1 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; @@ -34,7 +35,7 @@ import { ResponseError, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -176,7 +177,7 @@ export abstract class PortfolioCalculator { } protected abstract calculateOverallPerformance( - positions: TimelinePosition[] + positions: PortfolioCalculatorPosition[] ): PortfolioSnapshot; @LogPerformance @@ -312,9 +313,7 @@ export abstract class PortfolioCalculator { const errors: ResponseError['errors'] = []; let hasAnySymbolMetricsErrors = false; - const positions: (TimelinePosition & { - includeInHoldings: boolean; - })[] = []; + const positions: PortfolioCalculatorPosition[] = []; const accumulatedValuesByDate: { [date: string]: { @@ -326,6 +325,7 @@ export abstract class PortfolioCalculator { totalInvestmentValueWithCurrencyEffect: Big; totalNetPerformanceValue: Big; totalNetPerformanceValueWithCurrencyEffect: Big; + totalNetWorthValueWithCurrencyEffect: Big; totalTimeWeightedInvestmentValue: Big; totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; }; @@ -340,6 +340,7 @@ export abstract class PortfolioCalculator { investmentValuesWithCurrencyEffect: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big }; netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + netWorthValuesWithCurrencyEffect: { [date: string]: Big }; timeWeightedInvestmentValues: { [date: string]: Big }; timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; }; @@ -356,6 +357,11 @@ export abstract class PortfolioCalculator { const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity); + const isCashInBaseCurrency = + item.assetSubClass === AssetSubClass.CASH && + item.currency === this.currency && + item.symbol === this.currency; + const { currentValues, currentValuesWithCurrencyEffect, @@ -396,17 +402,35 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; + // Cash in the base currency cannot generate a currency effect and thus + // contributes nothing but its balance to the performance calculation. It + // is therefore excluded from the value and the investment, while still + // contributing to the net worth. + valuesBySymbol[item.symbol] = isCashInBaseCurrency + ? { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect, + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {} + } + : { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect + }; positions.push({ timeWeightedInvestment, @@ -431,6 +455,7 @@ export abstract class PortfolioCalculator { ? (grossPerformanceWithCurrencyEffect ?? null) : null, includeInHoldings: item.includeInHoldings, + includeInPerformance: !isCashInBaseCurrency, investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, marketPrice: @@ -508,6 +533,10 @@ export abstract class PortfolioCalculator { symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? new Big(0); + const netWorthValueWithCurrencyEffect = + symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const timeWeightedInvestmentValue = symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); @@ -526,7 +555,7 @@ export abstract class PortfolioCalculator { ?.totalCashValueWithCurrencyEffect ?? new Big(0) ).add( cashSymbols.has(symbol) - ? currentValueWithCurrencyEffect + ? netWorthValueWithCurrencyEffect : new Big(0) ), totalCurrentValue: ( @@ -552,6 +581,10 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) ).add(netPerformanceValueWithCurrencyEffect), + totalNetWorthValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalNetWorthValueWithCurrencyEffect ?? new Big(0) + ).add(netWorthValueWithCurrencyEffect), totalTimeWeightedInvestmentValue: ( accumulatedValuesByDate[dateString] ?.totalTimeWeightedInvestmentValue ?? new Big(0) @@ -576,6 +609,7 @@ export abstract class PortfolioCalculator { totalInvestmentValueWithCurrencyEffect, totalNetPerformanceValue, totalNetPerformanceValueWithCurrencyEffect, + totalNetWorthValueWithCurrencyEffect, totalTimeWeightedInvestmentValue, totalTimeWeightedInvestmentValueWithCurrencyEffect } = values; @@ -602,7 +636,7 @@ export abstract class PortfolioCalculator { netPerformance: totalNetPerformanceValue.toNumber(), netPerformanceWithCurrencyEffect: totalNetPerformanceValueWithCurrencyEffect.toNumber(), - netWorth: totalCurrentValueWithCurrencyEffect.toNumber(), + netWorth: totalNetWorthValueWithCurrencyEffect.toNumber(), totalCashInBaseCurrency: totalCashValueWithCurrencyEffect.toNumber(), totalInvestment: totalInvestmentValue.toNumber(), totalInvestmentValueWithCurrencyEffect: @@ -619,7 +653,7 @@ export abstract class PortfolioCalculator { return includeInHoldings; }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(({ includeInHoldings, ...rest }) => { + .map(({ includeInHoldings, includeInPerformance, ...rest }) => { return rest; }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 551189fcc..9bef6ad39 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/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', () => { @@ -314,5 +319,287 @@ describe('PortfolioCalculator', () => { 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) + }); + + /** + * Value: 0 CHF (the cash is excluded from the performance calculation + * and therefore from the value it is measured against) + * Net worth: 2000 CHF (the cash still counts towards the net worth) + */ + 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: 0, + 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 + }); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 18a8f7cd8..9a87af153 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -1,4 +1,5 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; @@ -7,7 +8,7 @@ import { AssetProfileIdentifier, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -26,7 +27,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { private chartDates: string[]; protected calculateOverallPerformance( - positions: TimelinePosition[] + positions: PortfolioCalculatorPosition[] ): PortfolioSnapshot { let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); @@ -41,12 +42,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { - if (currentPosition.feeInBaseCurrency) { - totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( - currentPosition.feeInBaseCurrency - ); - } - if (currentPosition.valueInBaseCurrency) { currentValueInBaseCurrency = currentValueInBaseCurrency.plus( currentPosition.valueInBaseCurrency @@ -55,6 +50,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { hasErrors = true; } + if (!currentPosition.includeInPerformance) { + continue; + } + + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + if (currentPosition.investment) { totalInvestment = totalInvestment.plus(currentPosition.investment); diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts new file mode 100644 index 000000000..f0cf8a774 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts @@ -0,0 +1,6 @@ +import { TimelinePosition } from '@ghostfolio/common/models'; + +export interface PortfolioCalculatorPosition extends TimelinePosition { + includeInHoldings: boolean; + includeInPerformance: boolean; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 175532cad..5239de24f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -208,7 +208,9 @@ export class PortfolioController { 'liabilitiesInBaseCurrency', 'netPerformance', 'netPerformanceWithCurrencyEffect', + 'totalAssetsInBaseCurrency', 'totalBuy', + 'totalCashInBaseCurrency', 'totalInvestment', 'totalInvestmentValueWithCurrencyEffect', 'totalSell', diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6c527ed2b..2eae63544 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/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,12 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(currentValueInBaseCurrency) + // Exclude emergency fund from the financial independence calculation + const fireWealthInBaseCurrency = new Big(totalAssetsInBaseCurrency).minus( + totalEmergencyFund + ); + + const netWorth = new Big(totalAssetsInBaseCurrency) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); @@ -1999,6 +2005,7 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, + currentValueInBaseCurrency, dateOfFirstActivity, excludedAccountsAndActivities, netPerformance, @@ -2010,7 +2017,6 @@ export class PortfolioService { activityCount: activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundHoldingsValueInBaseCurrency, @@ -2026,10 +2032,9 @@ export class PortfolioService { : undefined, fireWealth: { today: { - valueInBaseCurrency: new Big(currentValueInBaseCurrency) - .minus(totalCashInBaseCurrency ?? 0) - .minus(emergencyFundHoldingsValueInBaseCurrency) - .toNumber() + valueInBaseCurrency: fireWealthInBaseCurrency.gt(0) + ? fireWealthInBaseCurrency.toNumber() + : 0 } }, grossPerformance: new Big(netPerformance).plus(fees).toNumber(), @@ -2040,6 +2045,8 @@ export class PortfolioService { .toNumber(), interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), + totalAssetsInBaseCurrency: totalAssetsInBaseCurrency.toNumber(), + totalCashInBaseCurrency: totalCashInBaseCurrency.toNumber(), totalInvestment: totalInvestment.toNumber(), totalInvestmentValueWithCurrencyEffect: totalInvestmentWithCurrencyEffect.toNumber(), diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index e14479425..fd0ac9b72 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -9,7 +9,7 @@ class="flex-nowrap px-3 py-1 row" [hidden]="summary?.activityCount === null" > -
+
{{ summary?.activityCount }} {summary?.activityCount, plural, =1 {activity} @@ -123,7 +123,7 @@
-
+
Net Performance
-
- Emergency Fund +
+ Cash @if ( !hasImpersonationId && summary?.totalValueInBaseCurrency > 0 && @@ -175,47 +175,10 @@ class="d-lg-inline-block d-none ml-2 small text-muted" [isPercent]="true" [locale]="locale" - [value]="isLoading ? undefined : emergencyFundPercentage" + [value]="isLoading ? undefined : cashPercentage" /> }
-
- @if ( - hasPermissionToUpdateUserSettings && - !isLoading && - !user?.settings?.isRestrictedView && - user?.subscription?.type !== 'Basic' - ) { - - } - -
-
-
-
Cash
-
-
Assets
-
- + @if (isLoading || summary?.emergencyFund?.cash > 0) { +
+
Buying Power
+
+ +
-
+
+
Emergency Fund
+
+ +
+
+ }
-
- Buying Power +
+ Holdings @if ( !hasImpersonationId && summary?.totalValueInBaseCurrency > 0 && @@ -254,20 +233,38 @@ class="d-lg-inline-block d-none ml-2 small text-muted" [isPercent]="true" [locale]="locale" - [value]="isLoading ? undefined : buyingPowerPercentage" + [value]="isLoading ? undefined : holdingsPercentage" /> }
-
+
+ @if (isLoading || summary?.emergencyFund?.assets > 0) { +
+
Emergency Fund
+
+ +
+
+ }
Excluded from Analysis @@ -333,7 +330,7 @@
-
+
Annualized Performance
@@ -354,6 +351,57 @@

+
+
+ Emergency Fund + @if ( + !hasImpersonationId && + summary?.totalValueInBaseCurrency > 0 && + user?.settings?.isExperimentalFeatures + ) { + + } +
+
+ @if ( + hasPermissionToUpdateUserSettings && + !isLoading && + !user?.settings?.isRestrictedView && + user?.subscription?.type !== 'Basic' + ) { + + } + +
+
Interest
diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss index 5d4e87f30..6feaa22d1 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss @@ -1,3 +1,11 @@ :host { display: block; + + .indent-1 { + margin-left: 1rem; + } + + .indent-2 { + margin-left: 2rem; + } } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index b1e32c6c3..8bc79b624 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -21,6 +21,7 @@ import { ellipsisHorizontalCircleOutline, informationCircleOutline } from 'ionicons/icons'; +import { isNumber } from 'lodash'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -55,9 +56,10 @@ export class GfPortfolioSummaryComponent implements OnChanges { addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline }); } - protected get buyingPowerPercentage() { + protected get cashPercentage() { return this.summary?.totalValueInBaseCurrency - ? this.summary.cash / this.summary.totalValueInBaseCurrency + ? this.summary.totalCashInBaseCurrency / + this.summary.totalValueInBaseCurrency : 0; } @@ -75,6 +77,27 @@ export class GfPortfolioSummaryComponent implements OnChanges { : 0; } + protected get holdingsInBaseCurrency() { + if ( + !isNumber(this.summary?.totalAssetsInBaseCurrency) || + !isNumber(this.summary?.totalCashInBaseCurrency) + ) { + return null; + } + + return ( + this.summary.totalAssetsInBaseCurrency - + this.summary.totalCashInBaseCurrency + ); + } + + protected get holdingsPercentage() { + return this.summary?.totalValueInBaseCurrency && + isNumber(this.holdingsInBaseCurrency) + ? this.holdingsInBaseCurrency / this.summary.totalValueInBaseCurrency + : 0; + } + public ngOnChanges() { if (this.summary) { if ( diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 8db6b39bb..e109a7e63 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -22,7 +22,9 @@ export interface PortfolioSummary extends PortfolioPerformance { grossPerformanceWithCurrencyEffect: number; interestInBaseCurrency: number; liabilitiesInBaseCurrency: number; + totalAssetsInBaseCurrency: number; totalBuy: number; + totalCashInBaseCurrency: number; totalSell: number; totalValueInBaseCurrency?: number; }