From bb445ddf2e27711f3d0728357fdb603c0c2a6fbc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 24 Sep 2024 19:45:48 +0200 Subject: [PATCH] Feature/improve experimental chart in account detail dialog (#3813) * Improve chart in account detail dialog * Update changelog --- CHANGELOG.md | 1 + .../account-balance.service.ts | 59 +++++++-- .../api/src/app/account/account.controller.ts | 3 +- .../calculator/portfolio-calculator.ts | 50 +++++--- .../src/app/portfolio/portfolio.service.ts | 35 +----- .../portfolio-snapshot.module.ts | 2 + .../portfolio-snapshot.processor.ts | 10 ++ .../account-detail-dialog.component.ts | 116 ++++++++++-------- .../account-detail-dialog.html | 28 ++--- .../account-balances-response.interface.ts | 2 +- 10 files changed, 181 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca42f8691..0ee84dd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the usability of various action menus by introducing horizontal lines to separate the delete action +- Improved the chart in the account detail dialog (experimental) - Aligned the holdings and regions of the public page with the allocations page - Considered the user’s language in the link of the access table to share the portfolio - Improved the language localization for German (`de`) diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 244b4c684..f2b5f907e 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -2,14 +2,18 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed. import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { resetHours } from '@ghostfolio/common/helper'; -import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; -import { UserWithSettings } from '@ghostfolio/common/types'; +import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; +import { + AccountBalancesResponse, + Filter, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; -import { parseISO } from 'date-fns'; +import { Big } from 'big.js'; +import { format, parseISO } from 'date-fns'; import { CreateAccountBalanceDto } from './create-account-balance.dto'; @@ -91,17 +95,55 @@ export class AccountBalanceService { return accountBalance; } + public async getAccountBalanceItems({ + filters, + userCurrency, + userId + }: { + filters?: Filter[]; + userCurrency: string; + userId: string; + }): Promise { + const { balances } = await this.getAccountBalances({ + filters, + userCurrency, + userId, + withExcludedAccounts: false // TODO + }); + const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = + {}; + const lastBalancesByAccount: { [accountId: string]: Big } = {}; + + for (const { accountId, date, valueInBaseCurrency } of balances) { + const formattedDate = format(date, DATE_FORMAT); + + lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); + + const totalBalance = getSum(Object.values(lastBalancesByAccount)); + + // Add or update the accumulated balance for this date + accumulatedBalancesByDate[formattedDate] = { + date: formattedDate, + value: totalBalance.toNumber() + }; + } + + return Object.values(accumulatedBalancesByDate); + } + @LogPerformance public async getAccountBalances({ filters, - user, + userCurrency, + userId, withExcludedAccounts }: { filters?: Filter[]; - user: UserWithSettings; + userCurrency: string; + userId: string; withExcludedAccounts?: boolean; }): Promise { - const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; + const where: Prisma.AccountBalanceWhereInput = { userId }; const accountFilter = filters?.find(({ type }) => { return type === 'ACCOUNT'; @@ -132,10 +174,11 @@ export class AccountBalanceService { balances: balances.map((balance) => { return { ...balance, + accountId: balance.Account.id, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( balance.value, balance.Account.currency, - user.Settings.settings.baseCurrency + userCurrency ) }; }) diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index d8c3dd002..44e136793 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -137,7 +137,8 @@ export class AccountController { ): Promise { return this.accountBalanceService.getAccountBalances({ filters: [{ id, type: 'ACCOUNT' }], - user: this.request.user + userCurrency: this.request.user.Settings.settings.baseCurrency, + userId: this.request.user.id }); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d2f68e628..ba3b60237 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -104,6 +104,10 @@ export abstract class PortfolioCalculator { let dateOfFirstActivity = new Date(); + if (this.accountBalanceItems[0]) { + dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date); + } + this.activities = activities .map( ({ @@ -269,6 +273,10 @@ export abstract class PortfolioCalculator { ) }); + for (const accountBalanceItem of this.accountBalanceItems) { + chartDateMap[accountBalanceItem.date] = true; + } + const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { return chartDate; }); @@ -447,9 +455,28 @@ export abstract class PortfolioCalculator { } } - let lastDate = chartDates[0]; + const accountBalanceItemsMap = this.accountBalanceItems.reduce( + (map, { date, value }) => { + map[date] = new Big(value); + + return map; + }, + {} as { [date: string]: Big } + ); + + const accountBalanceMap: { [date: string]: Big } = {}; + + let lastKnownBalance = new Big(0); for (const dateString of chartDates) { + if (accountBalanceItemsMap[dateString] !== undefined) { + // If there's an exact balance for this date, update lastKnownBalance + lastKnownBalance = accountBalanceItemsMap[dateString]; + } + + // Add the most recent balance to the accountBalanceMap + accountBalanceMap[dateString] = lastKnownBalance; + for (const symbol of Object.keys(valuesBySymbol)) { const symbolValues = valuesBySymbol[symbol]; @@ -492,18 +519,7 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.investmentValueWithCurrencyEffect ?? new Big(0) ).add(investmentValueWithCurrencyEffect), - totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some( - ({ date }) => { - return date === dateString; - } - ) - ? new Big( - this.accountBalanceItems.find(({ date }) => { - return date === dateString; - }).value - ) - : (accumulatedValuesByDate[lastDate] - ?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)), + totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString], totalCurrentValue: ( accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) ).add(currentValue), @@ -537,8 +553,6 @@ export abstract class PortfolioCalculator { ).add(timeWeightedInvestmentValueWithCurrencyEffect) }; } - - lastDate = dateString; } const historicalData: HistoricalDataItem[] = Object.entries( @@ -733,12 +747,12 @@ export abstract class PortfolioCalculator { timeWeightedInvestmentValue === 0 ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / - timeWeightedInvestmentValue, + timeWeightedInvestmentValue // TODO: Add net worth with valuables // netWorth: totalCurrentValueWithCurrencyEffect // .plus(totalAccountBalanceWithCurrencyEffect) // .toNumber() - netWorth: 0 + // netWorth: 0 }); } } @@ -815,7 +829,7 @@ export abstract class PortfolioCalculator { endDate: Date; startDate: Date; step: number; - }) { + }): { [date: string]: true } { // Create a map of all relevant chart dates: // 1. Add transaction point dates let chartDateMap = this.transactionPoints.reduce((result, { date }) => { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 39ac9cc6f..c2c867f61 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1058,35 +1058,12 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const accountBalances = await this.accountBalanceService.getAccountBalances( - { filters, user, withExcludedAccounts } - ); - - let accountBalanceItems: HistoricalDataItem[] = Object.values( - // Reduce the array to a map with unique dates as keys - accountBalances.balances.reduce( - ( - map: { [date: string]: HistoricalDataItem }, - { date, valueInBaseCurrency } - ) => { - const formattedDate = format(date, DATE_FORMAT); - - 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; - }, - {} - ) - ); + const accountBalanceItems = + await this.accountBalanceService.getAccountBalanceItems({ + filters, + userId, + userCurrency + }); const { activities } = await this.orderService.getOrdersForPortfolioCalculator({ diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts index 331e5849f..620feda53 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -1,3 +1,4 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; @@ -17,6 +18,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; @Module({ exports: [BullModule, PortfolioSnapshotService], imports: [ + AccountBalanceModule, BullModule.registerQueue({ name: PORTFOLIO_SNAPSHOT_QUEUE }), diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index b3ef4eb88..7c89e9c23 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -1,3 +1,4 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { PerformanceCalculationType, @@ -24,6 +25,7 @@ import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queu @Processor(PORTFOLIO_SNAPSHOT_QUEUE) export class PortfolioSnapshotProcessor { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly configurationService: ConfigurationService, private readonly orderService: OrderService, @@ -56,7 +58,15 @@ export class PortfolioSnapshotProcessor { userId: job.data.userId }); + const accountBalanceItems = + await this.accountBalanceService.getAccountBalanceItems({ + filters: job.data.filters, + userCurrency: job.data.userCurrency, + userId: job.data.userId + }); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, activities, calculationType: PerformanceCalculationType.TWR, currency: job.data.userCurrency, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 1cec23aba..d6791760b 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -2,7 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { downloadAsFile } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, HistoricalDataItem, @@ -27,7 +27,7 @@ import { Router } from '@angular/router'; import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; import { isNumber } from 'lodash'; -import { Subject } from 'rxjs'; +import { forkJoin, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { AccountDetailDialogParams } from './interfaces/interfaces'; @@ -87,11 +87,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchActivities(); - this.fetchPortfolioHoldings(); - this.fetchPortfolioPerformance(); + this.initialize(); } public onCloneActivity(aActivity: Activity) { @@ -111,9 +107,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .postAccountBalance(accountBalance) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); + this.initialize(); }); } @@ -122,9 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .deleteAccountBalance(aId) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); + this.initialize(); }); } @@ -198,17 +190,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit { ); } - private fetchAccountBalances() { - this.dataService - .fetchAccountBalances(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ balances }) => { - this.accountBalances = balances; - - this.changeDetectorRef.markForCheck(); - }); - } - private fetchActivities() { this.isLoadingActivities = true; @@ -229,6 +210,58 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } + private fetchChart() { + this.isLoadingChart = true; + + forkJoin({ + accountBalances: this.dataService + .fetchAccountBalances(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)), + portfolioPerformance: this.dataService + .fetchPortfolioPerformance({ + filters: [ + { + id: this.data.accountId, + type: 'ACCOUNT' + } + ], + range: 'max', + withExcludedAccounts: true, + withItems: true + }) + .pipe(takeUntil(this.unsubscribeSubject)) + }).subscribe({ + error: () => { + this.isLoadingChart = false; + }, + next: ({ accountBalances, portfolioPerformance }) => { + this.accountBalances = accountBalances.balances; + + if (portfolioPerformance.chart.length > 0) { + this.historicalDataItems = portfolioPerformance.chart.map( + ({ date, netWorth, netWorthInPercentage }) => ({ + date, + value: isNumber(netWorth) ? netWorth : netWorthInPercentage + }) + ); + } else { + this.historicalDataItems = this.accountBalances.map( + ({ date, valueInBaseCurrency }) => { + return { + date: format(date, DATE_FORMAT), + value: valueInBaseCurrency + }; + } + ); + } + + this.isLoadingChart = false; + + this.changeDetectorRef.markForCheck(); + } + }); + } + private fetchPortfolioHoldings() { this.dataService .fetchPortfolioHoldings({ @@ -247,36 +280,11 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } - private fetchPortfolioPerformance() { - this.isLoadingChart = true; - - this.dataService - .fetchPortfolioPerformance({ - filters: [ - { - id: this.data.accountId, - type: 'ACCOUNT' - } - ], - range: 'max', - withExcludedAccounts: true, - withItems: true - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ chart }) => { - this.historicalDataItems = chart.map( - ({ date, netWorth, netWorthInPercentage }) => { - return { - date, - value: isNumber(netWorth) ? netWorth : netWorthInPercentage - }; - } - ); - - this.isLoadingChart = false; - - this.changeDetectorRef.markForCheck(); - }); + private initialize() { + this.fetchAccount(); + this.fetchActivities(); + this.fetchChart(); + this.fetchPortfolioHoldings(); } public ngOnDestroy() { diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 9f55250ec..8459037f6 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -20,20 +20,20 @@ - + @if (user?.settings?.isExperimentalFeatures) { +
+ +
+ }
diff --git a/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts b/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts index 98a765e8a..a623baaff 100644 --- a/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts @@ -1,7 +1,7 @@ import { AccountBalance } from '@prisma/client'; export interface AccountBalancesResponse { - balances: (Pick & { + balances: (Pick & { valueInBaseCurrency: number; })[]; }