From 2b491dc7322dee91a558950ee89d57642ce3284f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 26 Nov 2023 21:17:15 +0100 Subject: [PATCH] Extend performance endpoint by net worth per day (#2574) * Extend performance endpoint by net worth per day * Update changelog --- CHANGELOG.md | 1 + .../api/src/app/account/account.controller.ts | 4 +- .../src/app/portfolio/portfolio.controller.ts | 33 +- .../src/app/portfolio/portfolio.service.ts | 292 ++++++++++++------ .../account-balance/account-balance.module.ts | 3 +- .../account-balance.service.ts | 46 ++- .../account-detail-dialog.component.ts | 6 +- .../historical-data-item.interface.ts | 3 + .../portfolio-performance.interface.ts | 1 + .../account-balances-response.interface.ts | 4 +- 10 files changed, 265 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ff90f055..53ffba787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the chart in the account detail dialog by historical cash balances - Improved the error log for a timeout in the data source request - Improved the language localization for German (`de`) - Upgraded `angular` from version `16.2.12` to `17.0.4` diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index e141dc11f..3eeb7117c 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -128,8 +128,8 @@ export class AccountController { @Param('id') id: string ): Promise { return this.accountBalanceService.getAccountBalances({ - accountId: id, - userId: this.request.user.id + filters: [{ id, type: 'ACCOUNT' }], + user: this.request.user }); } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 1c022ba17..dd013989f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -346,16 +346,34 @@ export class PortfolioController { this.userService.isRestrictedView(this.request.user) ) { performanceInformation.chart = performanceInformation.chart.map( - ({ date, netPerformanceInPercentage, totalInvestment, value }) => { + ({ + date, + netPerformanceInPercentage, + netWorth, + totalInvestment, + value + }) => { return { date, netPerformanceInPercentage, - totalInvestment: new Big(totalInvestment) - .div(performanceInformation.performance.totalInvestment) - .toNumber(), - valueInPercentage: new Big(value) - .div(performanceInformation.performance.currentValue) - .toNumber() + netWorthInPercentage: + performanceInformation.performance.currentNetWorth === 0 + ? 0 + : new Big(netWorth) + .div(performanceInformation.performance.currentNetWorth) + .toNumber(), + totalInvestment: + performanceInformation.performance.totalInvestment === 0 + ? 0 + : new Big(totalInvestment) + .div(performanceInformation.performance.totalInvestment) + .toNumber(), + valueInPercentage: + performanceInformation.performance.currentValue === 0 + ? 0 + : new Big(value) + .div(performanceInformation.performance.currentValue) + .toNumber() }; } ); @@ -365,6 +383,7 @@ export class PortfolioController { [ 'currentGrossPerformance', 'currentNetPerformance', + 'currentNetWorth', 'currentValue', 'totalInvestment' ] diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 2a701aab5..b5bc589e6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -12,6 +12,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; @@ -67,14 +68,16 @@ import { isBefore, isSameMonth, isSameYear, + isValid, max, + min, parseISO, set, setDayOfYear, subDays, subYears } from 'date-fns'; -import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; +import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; import { HistoricalDataContainer, @@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, @@ -114,8 +118,12 @@ export class PortfolioService { }): Promise { const where: Prisma.AccountWhereInput = { userId: userId }; - if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') { - where.id = filters[0].id; + const accountFilter = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + }); + + if (accountFilter) { + where.id = accountFilter.id; } const [accounts, details] = await Promise.all([ @@ -267,6 +275,13 @@ export class PortfolioService { includeDrafts: true }); + if (transactionPoints.length === 0) { + return { + investments: [], + streaks: { currentStreak: 0, longestStreak: 0 } + }; + } + const portfolioCalculator = new PortfolioCalculator({ currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, @@ -274,12 +289,6 @@ export class PortfolioService { }); portfolioCalculator.setTransactionPoints(transactionPoints); - if (transactionPoints.length === 0) { - return { - investments: [], - streaks: { currentStreak: 0, longestStreak: 0 } - }; - } let investments: InvestmentItem[]; @@ -367,67 +376,6 @@ export class PortfolioService { }; } - public async getChart({ - dateRange = 'max', - filters, - impersonationId, - userCurrency, - userId, - withExcludedAccounts = false - }: { - dateRange?: DateRange; - filters?: Filter[]; - impersonationId: string; - userCurrency: string; - userId: string; - withExcludedAccounts?: boolean; - }): Promise { - userId = await this.getUserId(impersonationId, userId); - - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - if (transactionPoints.length === 0) { - return { - isAllTimeHigh: false, - isAllTimeLow: false, - items: [] - }; - } - const endDate = new Date(); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - - const daysInMarket = differenceInDays(new Date(), startDate); - const step = Math.round( - daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) - ); - - const items = await portfolioCalculator.getChartData( - startDate, - endDate, - step - ); - - return { - items, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - public async getDetails({ dateRange = 'max', filters, @@ -1028,12 +976,6 @@ export class PortfolioService { userId }); - const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - if (transactionPoints?.length <= 0) { return { hasErrors: false, @@ -1041,6 +983,12 @@ export class PortfolioService { }; } + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.settings.baseCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); @@ -1126,6 +1074,31 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); + const accountBalances = await this.accountBalanceService.getAccountBalances( + { filters, user } + ); + + 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); + + // Store the item in the map, overwriting if the date already exists + map[formattedDate] = { + date: formattedDate, + value: valueInBaseCurrency + }; + + return map; + }, + {} + ) + ); + const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ filters, @@ -1139,7 +1112,7 @@ export class PortfolioService { orders: portfolioOrders }); - if (transactionPoints?.length <= 0) { + if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { return { chart: [], firstOrderDate: undefined, @@ -1149,6 +1122,7 @@ export class PortfolioService { currentGrossPerformancePercent: 0, currentNetPerformance: 0, currentNetPerformancePercent: 0, + currentNetWorth: 0, currentValue: 0, totalInvestment: 0 } @@ -1157,7 +1131,15 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); - const portfolioStart = parseDate(transactionPoints[0].date); + const portfolioStart = min( + [ + parseDate(accountBalanceItems[0]?.date), + parseDate(transactionPoints[0]?.date) + ].filter((date) => { + return isValid(date); + }) + ); + const startDate = this.getStartDate(dateRange, portfolioStart); const { currentValue, @@ -1175,17 +1157,17 @@ export class PortfolioService { let currentNetPerformance = netPerformance; let currentNetPerformancePercent = netPerformancePercentage; - const historicalDataContainer = await this.getChart({ + const { items } = await this.getChart({ dateRange, - filters, impersonationId, + portfolioOrders, + transactionPoints, userCurrency, - userId, - withExcludedAccounts + userId }); - const itemOfToday = historicalDataContainer.items.find((item) => { - return item.date === format(new Date(), DATE_FORMAT); + const itemOfToday = items.find(({ date }) => { + return date === format(new Date(), DATE_FORMAT); }); if (itemOfToday) { @@ -1195,34 +1177,42 @@ export class PortfolioService { ).div(100); } + 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 + }); + } + + const mergedHistoricalDataItems = this.mergeHistoricalDataItems( + accountBalanceItems, + items + ); + + const currentHistoricalDataItem = last(mergedHistoricalDataItems); + const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0; + return { errors, hasErrors, - chart: historicalDataContainer.items.map( - ({ - date, - netPerformance: netPerformanceOfItem, - netPerformanceInPercentage, - totalInvestment: totalInvestmentOfItem, - value - }) => { - return { - date, - netPerformanceInPercentage, - value, - netPerformance: netPerformanceOfItem, - totalInvestment: totalInvestmentOfItem - }; - } - ), - firstOrderDate: parseDate(historicalDataContainer.items[0]?.date), + chart: mergedHistoricalDataItems, + firstOrderDate: parseDate(items[0]?.date), performance: { - currentValue: currentValue.toNumber(), + currentNetWorth, currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformancePercent: currentGrossPerformancePercent.toNumber(), currentNetPerformance: currentNetPerformance.toNumber(), currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), + currentValue: currentValue.toNumber(), totalInvestment: totalInvestment.toNumber() } }; @@ -1376,6 +1366,62 @@ export class PortfolioService { return cashPositions; } + private async getChart({ + dateRange = 'max', + impersonationId, + portfolioOrders, + transactionPoints, + userCurrency, + userId + }: { + dateRange?: DateRange; + impersonationId: string; + portfolioOrders: PortfolioOrder[]; + transactionPoints: TransactionPoint[]; + userCurrency: string; + userId: string; + }): Promise { + if (transactionPoints.length === 0) { + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: [] + }; + } + + userId = await this.getUserId(impersonationId, userId); + + const portfolioCalculator = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const endDate = new Date(); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(dateRange, portfolioStart); + + const daysInMarket = differenceInDays(new Date(), startDate); + const step = Math.round( + daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) + ); + + const items = await portfolioCalculator.getChartData( + startDate, + endDate, + step + ); + + return { + items, + isAllTimeHigh: false, + isAllTimeLow: false + }; + } + private getDividendsByGroup({ dividends, groupBy @@ -1999,4 +2045,44 @@ 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) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + return historicalDataItems; + } } diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/services/account-balance/account-balance.module.ts index 53c695b5f..c85727f8c 100644 --- a/apps/api/src/services/account-balance/account-balance.module.ts +++ b/apps/api/src/services/account-balance/account-balance.module.ts @@ -1,10 +1,11 @@ import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; @Module({ exports: [AccountBalanceService], - imports: [PrismaModule], + imports: [ExchangeRateDataModule, PrismaModule], providers: [AccountBalanceService] }) export class AccountBalanceModule {} diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/services/account-balance/account-balance.service.ts index 9995bbc3e..33b811ef5 100644 --- a/apps/api/src/services/account-balance/account-balance.service.ts +++ b/apps/api/src/services/account-balance/account-balance.service.ts @@ -1,11 +1,16 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { AccountBalancesResponse } from '@ghostfolio/common/interfaces'; +import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { AccountBalance, Prisma } from '@prisma/client'; @Injectable() export class AccountBalanceService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService + ) {} public async createAccountBalance( data: Prisma.AccountBalanceCreateInput @@ -16,27 +21,46 @@ export class AccountBalanceService { } public async getAccountBalances({ - accountId, - userId + filters, + user }: { - accountId: string; - userId: string; + filters?: Filter[]; + user: UserWithSettings; }): Promise { + const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; + + const accountFilter = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + }); + + if (accountFilter) { + where.accountId = accountFilter.id; + } + const balances = await this.prismaService.accountBalance.findMany({ + where, orderBy: { date: 'asc' }, select: { + Account: true, date: true, id: true, value: true - }, - where: { - accountId, - userId } }); - return { balances }; + return { + balances: balances.map((balance) => { + return { + ...balance, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + balance.value, + balance.Account.currency, + user.Settings.settings.baseCurrency + ) + }; + }) + }; } } 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 d232cb3df..284bfcca2 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 @@ -122,13 +122,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart }) => { this.historicalDataItems = chart.map( - ({ date, value, valueInPercentage }) => { + ({ date, netWorth, netWorthInPercentage }) => { return { date, value: this.hasImpersonationId || this.user.settings.isRestrictedView - ? valueInPercentage - : value + ? netWorthInPercentage + : netWorth }; } ); diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index b8306ff86..b348e33aa 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -5,7 +5,10 @@ export interface HistoricalDataItem { marketPrice?: number; netPerformance?: number; netPerformanceInPercentage?: number; + netWorth?: number; + netWorthInPercentage?: number; quantity?: number; + totalAccountBalance?: number; totalInvestment?: number; value?: number; valueInPercentage?: number; diff --git a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts index 5a4f91023..0343ef338 100644 --- a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts @@ -4,6 +4,7 @@ export interface PortfolioPerformance { currentGrossPerformancePercent: number; currentNetPerformance: number; currentNetPerformancePercent: number; + currentNetWorth: number; currentValue: number; totalInvestment: number; } 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 9b4ec2f6d..98a765e8a 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,5 +1,7 @@ import { AccountBalance } from '@prisma/client'; export interface AccountBalancesResponse { - balances: Pick[]; + balances: (Pick & { + valueInBaseCurrency: number; + })[]; }