Feature/improve chart in account detail dialog (#3314)

* Improve net worth calculation in portfolio performance chart

* Improve account balance management

* Update changelog
pull/3317/head
Thomas Kaul 4 weeks ago committed by GitHub
parent ab59eb5c92
commit 39bd4a349b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Improved the chart in the account detail dialog
- Improved the account balance management
## 2.75.0 - 2024-04-21
### Added

@ -1,12 +1,13 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { parseISO } from 'date-fns';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -86,15 +87,11 @@ export class AccountService {
data
});
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
return account;
@ -197,15 +194,11 @@ export class AccountService {
): Promise<Account> {
const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: <string>data.id,
balance: <number>data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
return this.prismaService.account.update({

@ -1,6 +1,7 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -22,11 +23,13 @@ export class PortfolioCalculatorFactory {
) {}
public createCalculator({
accountBalanceItems = [],
activities,
calculationType,
currency,
dateRange = 'max'
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
@ -35,6 +38,7 @@ export class PortfolioCalculatorFactory {
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
@ -43,6 +47,7 @@ export class PortfolioCalculatorFactory {
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,

@ -37,13 +37,15 @@ import {
isBefore,
isSameDay,
max,
min,
subDays
} from 'date-fns';
import { last, uniq, uniqBy } from 'lodash';
import { first, last, uniq, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
protected accountBalanceItems: HistoricalDataItem[];
protected orders: PortfolioOrder[];
private currency: string;
@ -57,18 +59,21 @@ export abstract class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
public constructor({
accountBalanceItems,
activities,
currency,
currentRateService,
dateRange,
exchangeRateDataService
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
}) {
this.accountBalanceItems = accountBalanceItems;
this.currency = currency;
this.currentRateService = currentRateService;
this.exchangeRateDataService = exchangeRateDataService;
@ -383,10 +388,6 @@ export abstract class PortfolioCalculator {
dateRange?: DateRange;
withDataDecimation?: boolean;
}): Promise<HistoricalDataItem[]> {
if (this.getTransactionPoints().length === 0) {
return [];
}
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const daysInMarket = differenceInDays(endDate, startDate) + 1;
@ -485,6 +486,7 @@ export abstract class PortfolioCalculator {
investmentValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
@ -544,9 +546,24 @@ export abstract class PortfolioCalculator {
};
}
let lastDate = format(this.startDate, DATE_FORMAT);
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: new Big(0),
totalAccountBalanceWithCurrencyEffect: new Big(0),
totalCurrentValue: new Big(0),
totalCurrentValueWithCurrencyEffect: new Big(0),
totalInvestmentValue: new Big(0),
totalInvestmentValueWithCurrencyEffect: new Big(0),
totalNetPerformanceValue: new Big(0),
totalNetPerformanceValueWithCurrencyEffect: new Big(0),
totalTimeWeightedInvestmentValue: new Big(0),
totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0)
};
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
@ -584,49 +601,94 @@ export abstract class PortfolioCalculator {
dateString
] ?? new Big(0);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalCurrentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect),
totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValueAccumulated),
totalInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect),
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue),
totalNetPerformanceValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(timeWeightedInvestmentValueWithCurrencyEffect)
};
accumulatedValuesByDate[dateString].investmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].investmentValueWithCurrencyEffect.add(
investmentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalCurrentValue =
accumulatedValuesByDate[dateString].totalCurrentValue.add(
currentValue
);
accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect.add(
currentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalInvestmentValue =
accumulatedValuesByDate[dateString].totalInvestmentValue.add(
investmentValueAccumulated
);
accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect.add(
investmentValueAccumulatedWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalNetPerformanceValue =
accumulatedValuesByDate[dateString].totalNetPerformanceValue.add(
netPerformanceValue
);
accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect.add(
netPerformanceValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalTimeWeightedInvestmentValue =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValue.add(timeWeightedInvestmentValue);
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect.add(
timeWeightedInvestmentValueWithCurrencyEffect
);
}
if (
this.accountBalanceItems.some(({ date }) => {
return date === dateString;
})
) {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect = new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
);
} else {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect =
accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0);
}
lastDate = dateString;
}
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
@ -661,6 +723,11 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
@ -749,9 +816,30 @@ export abstract class PortfolioCalculator {
}
public getStartDate() {
return this.transactionPoints.length > 0
? parseDate(this.transactionPoints[0].date)
: new Date();
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
protected abstract getSymbolMetrics({

@ -90,7 +90,12 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
});
});
});

@ -113,6 +113,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
@ -126,6 +128,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,

@ -188,6 +188,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
[date: string]: Big;
} = {};
let totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
@ -237,6 +238,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
@ -286,6 +288,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
@ -875,6 +878,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,

@ -64,7 +64,6 @@ import {
differenceInDays,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
@ -1056,11 +1055,16 @@ export class PortfolioService {
) => {
const formattedDate = format(date, DATE_FORMAT);
// Store the item in the map, overwriting if the date already exists
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
if (map[formattedDate]) {
// If the value exists, add the current value to the existing one
map[formattedDate].value += valueInBaseCurrency;
} else {
// Otherwise, initialize the value for that date
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
}
return map;
},
@ -1100,6 +1104,7 @@ export class PortfolioService {
}
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
dateRange,
calculationType: PerformanceCalculationType.TWR,
@ -1131,6 +1136,8 @@ export class PortfolioService {
let currentNetPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
let currentNetWorth = 0;
const items = await portfolioCalculator.getChart({
dateRange
});
@ -1153,35 +1160,14 @@ export class PortfolioService {
currentNetPerformanceWithCurrencyEffect = new Big(
itemOfToday.netPerformanceWithCurrencyEffect
);
}
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (!accountBalanceItemOfToday) {
accountBalanceItems.push({
date: format(new Date(), DATE_FORMAT),
value: last(accountBalanceItems)?.value ?? 0
});
currentNetWorth = itemOfToday.netWorth;
}
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
accountBalanceItems,
items
);
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
return {
errors,
hasErrors,
chart: mergedHistoricalDataItems,
chart: items,
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentNetWorth,
@ -1909,44 +1895,4 @@ export class PortfolioService {
return { accounts, platforms };
}
private mergeHistoricalDataItems(
accountBalanceItems: HistoricalDataItem[],
performanceChartItems: HistoricalDataItem[]
): HistoricalDataItem[] {
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
let latestAccountBalance = 0;
for (const item of accountBalanceItems.concat(performanceChartItems)) {
const isAccountBalanceItem = accountBalanceItems.includes(item);
const totalAccountBalance = isAccountBalanceItem
? item.value
: latestAccountBalance;
if (isAccountBalanceItem && performanceChartItems.length > 0) {
latestAccountBalance = item.value;
} else {
historicalDataItemsMap[item.date] = {
...item,
totalAccountBalance,
netWorth:
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
};
}
}
// Convert to an array and sort by date in ascending order
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
(date) => {
return historicalDataItemsMap[date];
}
);
historicalDataItems.sort((a, b) => {
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
return historicalDataItems;
}
}

@ -84,55 +84,10 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public ngOnInit() {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
balance,
currency,
name,
Platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.balance = balance;
this.currency = currency;
if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber();
} else {
this.equity = null;
}
this.name = name;
this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
}
);
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.fetchAccount();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioHoldings();
this.fetchPortfolioPerformance();
}
@ -155,6 +110,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
});
@ -165,6 +121,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
});
@ -199,6 +156,39 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchActivities();
}
private fetchAccount() {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
balance,
currency,
name,
Platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.balance = balance;
this.currency = currency;
if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber();
} else {
this.equity = null;
}
this.name = name;
this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
}
);
}
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
@ -230,6 +220,24 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
});
}
private fetchPortfolioHoldings() {
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
private fetchPortfolioPerformance() {
this.isLoadingChart = true;
@ -251,11 +259,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
({ date, netWorth, netWorthInPercentage }) => {
return {
date,
value:
this.data.hasImpersonationId ||
this.user.settings.isRestrictedView
? netWorthInPercentage
: netWorth
value: isNumber(netWorth) ? netWorth : netWorthInPercentage
};
}
);

@ -233,6 +233,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccounts();
this.router.navigate(['.'], { relativeTo: this.route });
});
}

@ -40,6 +40,7 @@ export interface SymbolMetrics {
[date: string]: Big;
};
timeWeightedInvestmentWithCurrencyEffect: Big;
totalAccountBalanceInBaseCurrency: Big;
totalDividend: Big;
totalDividendInBaseCurrency: Big;
totalInterest: Big;

Loading…
Cancel
Save