Browse Source

Task/exclude cash in base currency from portfolio performance calculation (#7443)

* Exclude cash in base currency from portfolio performance calculation

* Update changelog
pull/7446/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
56bb984aa3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 72
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 289
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  4. 21
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  5. 6
      apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts
  6. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 23
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 174
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  9. 8
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss
  10. 27
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  11. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts

4
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

72
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;
});

289
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<Partial<TimelinePosition>>({
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
});
});
});
});

21
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);

6
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;
}

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

@ -208,7 +208,9 @@ export class PortfolioController {
'liabilitiesInBaseCurrency',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalAssetsInBaseCurrency',
'totalBuy',
'totalCashInBaseCurrency',
'totalInvestment',
'totalInvestmentValueWithCurrencyEffect',
'totalSell',

23
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(),

174
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"
>
<div class="d-flex flex-grow-1 ml-3 text-truncate">
<div class="d-flex flex-grow-1 indent-1 text-truncate">
{{ summary?.activityCount }}
<ng-container i18n>{summary?.activityCount, plural,
=1 {activity}
@ -123,7 +123,7 @@
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3">
<div class="flex-grow-1 indent-1 text-truncate">
<ng-container i18n>Net Performance</ng-container>
<abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted"
@ -159,13 +159,13 @@
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValueInBaseCurrency"
[value]="isLoading ? undefined : summary?.totalAssetsInBaseCurrency"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1">
<ng-container i18n>Emergency Fund</ng-container>
<div class="align-items-center d-flex flex-grow-1 indent-1">
<ng-container i18n>Cash</ng-container>
@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"
/>
}
</div>
<div
class="align-items-center d-flex justify-content-end"
[class.cursor-pointer]="
hasPermissionToUpdateUserSettings &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic'
"
(click)="
hasPermissionToUpdateUserSettings &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic' &&
onEditEmergencyFund()
"
>
@if (
hasPermissionToUpdateUserSettings &&
!isLoading &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic'
) {
<ion-icon
class="mr-1 text-muted"
name="ellipsis-horizontal-circle-outline"
/>
}
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.total"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<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"
@ -224,27 +187,43 @@
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.cash"
[value]="isLoading ? undefined : summary?.totalCashInBaseCurrency"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Assets</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?.emergencyFund?.assets"
/>
@if (isLoading || summary?.emergencyFund?.cash > 0) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-2 text-truncate" i18n>Buying Power</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?.cash"
/>
</div>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-2 text-truncate" i18n>Emergency Fund</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?.emergencyFund?.cash"
/>
</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>
<div class="align-items-center d-flex flex-grow-1 indent-1">
<ng-container i18n>Holdings</ng-container>
@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"
/>
}
</div>
<div class="align-items-center d-flex justify-content-end">
<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?.cash"
[value]="isLoading ? undefined : holdingsInBaseCurrency"
/>
</div>
</div>
@if (isLoading || summary?.emergencyFund?.assets > 0) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-2 text-truncate" i18n>Emergency Fund</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?.emergencyFund?.assets"
/>
</div>
</div>
}
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1">
<ng-container i18n>Excluded from Analysis</ng-container>
@ -333,7 +330,7 @@
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate">
<div class="flex-grow-1 indent-1 text-truncate">
<ng-container i18n>Annualized Performance</ng-container>
</div>
<div class="flex-column flex-wrap justify-content-end">
@ -354,6 +351,57 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1">
<ng-container i18n>Emergency Fund</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 : emergencyFundPercentage"
/>
}
</div>
<div
class="align-items-center d-flex justify-content-end"
[class.cursor-pointer]="
hasPermissionToUpdateUserSettings &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic'
"
(click)="
hasPermissionToUpdateUserSettings &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic' &&
onEditEmergencyFund()
"
>
@if (
hasPermissionToUpdateUserSettings &&
!isLoading &&
!user?.settings?.isRestrictedView &&
user?.subscription?.type !== 'Basic'
) {
<ion-icon
class="mr-1 text-muted"
name="ellipsis-horizontal-circle-outline"
/>
}
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[precision]="precision"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.total"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Interest</div>
<div class="justify-content-end">

8
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;
}
}

27
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 (

2
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;
}

Loading…
Cancel
Save