diff --git a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts index 82d9f813c..1856e183f 100644 --- a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts @@ -1,27 +1,34 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { getFactor, getInterval } from '@ghostfolio/api/helper/portfolio.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; -import { Logger } from '@nestjs/common'; +import { Inject, Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { addDays, differenceInDays, eachDayOfInterval, + endOfDay, format, isAfter, isBefore, - isEqual, subDays } from 'date-fns'; +import { CurrentRateService } from '../../current-rate.service'; +import { DateQuery } from '../../interfaces/date-query.interface'; import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; import { TWRPortfolioCalculator } from '../twr/portfolio-calculator'; @@ -29,6 +36,47 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { private holdings: { [date: string]: { [symbol: string]: Big } } = {}; private holdingCurrencies: { [symbol: string]: string } = {}; + constructor( + { + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + dateRange, + exchangeRateDataService, + redisCacheService, + useCache, + userId + }: { + accountBalanceItems: HistoricalDataItem[]; + activities: Activity[]; + configurationService: ConfigurationService; + currency: string; + currentRateService: CurrentRateService; + dateRange: DateRange; + exchangeRateDataService: ExchangeRateDataService; + redisCacheService: RedisCacheService; + useCache: boolean; + userId: string; + }, + @Inject() + private orderService: OrderService + ) { + super({ + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + dateRange, + exchangeRateDataService, + redisCacheService, + useCache, + userId + }); + } + @LogPerformance public async getChart({ dateRange = 'max', @@ -77,6 +125,77 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { } } + @LogPerformance + public async getUnfilteredNetWorth(currency: string): Promise { + const activities = await this.orderService.getOrders({ + userId: this.userId, + userCurrency: currency, + types: ['BUY', 'SELL', 'STAKE'], + withExcludedAccounts: true + }); + const orders = this.activitiesToPortfolioOrder(activities.activities); + const start = orders.reduce( + (date, order) => + parseDate(date.date).getTime() < parseDate(order.date).getTime() + ? date + : order, + { date: orders[0].date } + ).date; + + const end = new Date(Date.now()); + + const holdings = await this.getHoldings(orders, parseDate(start), end); + const marketMap = await this.currentRateService.getToday( + this.mapToDataGatheringItems(orders) + ); + const endString = format(end, DATE_FORMAT); + let exchangeRates = await Promise.all( + Object.keys(holdings[endString]).map(async (holding) => { + let symbol = marketMap.find((m) => m.symbol === holding); + let symbolCurrency = this.getCurrencyFromActivities(orders, holding); + let exchangeRate = await this.exchangeRateDataService.toCurrencyAtDate( + 1, + symbolCurrency, + this.currency, + end + ); + return { symbolCurrency, exchangeRate }; + }) + ); + let currencyRates = exchangeRates.reduce<{ [currency: string]: number }>( + (all, currency): { [currency: string]: number } => { + all[currency.symbolCurrency] ??= currency.exchangeRate; + return all; + }, + {} + ); + + let totalInvestment = await Object.keys(holdings[endString]).reduce( + (sum, holding) => { + if (!holdings[endString][holding].toNumber()) { + return sum; + } + let symbol = marketMap.find((m) => m.symbol === holding); + + if (symbol?.marketPrice === undefined) { + Logger.warn( + `Missing historical market data for ${holding} (${end})`, + 'PortfolioCalculator' + ); + return sum; + } else { + let symbolCurrency = this.getCurrency(holding); + let price = new Big(currencyRates[symbolCurrency]).mul( + symbol.marketPrice + ); + return sum.plus(new Big(price).mul(holdings[endString][holding])); + } + }, + new Big(0) + ); + return totalInvestment; + } + @LogPerformance private async getTimeWeightedChartData({ dates @@ -86,8 +205,15 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { dates = dates.sort((a, b) => a.getTime() - b.getTime()); const start = dates[0]; const end = dates[dates.length - 1]; - let marketMapTask = this.computeMarketMap({ gte: start, lte: end }); - const timelineHoldings = await this.getHoldings(start, end); + let marketMapTask = this.computeMarketMap({ + gte: start, + lt: addDays(end, 1) + }); + const timelineHoldings = await this.getHoldings( + this.activities, + start, + end + ); let data: HistoricalDataItem[] = []; const startString = format(start, DATE_FORMAT); @@ -107,7 +233,7 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { valueWithCurrencyEffect: 0 }); - await marketMapTask; + this.marketMap = await marketMapTask; let totalInvestment = Object.keys(timelineHoldings[startString]).reduce( (sum, holding) => { @@ -262,8 +388,16 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { @LogPerformance private getCurrency(symbol: string) { + return this.getCurrencyFromActivities(this.activities, symbol); + } + + @LogPerformance + private getCurrencyFromActivities( + activities: PortfolioOrder[], + symbol: string + ) { if (!this.holdingCurrencies[symbol]) { - this.holdingCurrencies[symbol] = this.activities.find( + this.holdingCurrencies[symbol] = activities.find( (a) => a.SymbolProfile.symbol === symbol ).SymbolProfile.currency; } @@ -272,7 +406,11 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { } @LogPerformance - private async getHoldings(start: Date, end: Date) { + private async getHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { if ( this.holdings && Object.keys(this.holdings).some((h) => @@ -285,13 +423,25 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { return this.holdings; } - this.computeHoldings(start, end); + this.computeHoldings(activities, start, end); return this.holdings; } @LogPerformance - private async computeHoldings(start: Date, end: Date) { - const investmentByDate = this.getInvestmentByDate(); + private async computeHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { + const investmentByDate = this.getInvestmentByDate(activities); + this.calculateHoldings(investmentByDate, start, end); + } + + private calculateHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + end: Date + ) { const transactionDates = Object.keys(investmentByDate).sort(); let dates = eachDayOfInterval({ start, end }, { step: 1 }) .map((date) => { @@ -354,8 +504,10 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { } @LogPerformance - private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } { - return this.activities.reduce((groupedByDate, order) => { + private getInvestmentByDate(activities: PortfolioOrder[]): { + [date: string]: PortfolioOrder[]; + } { + return activities.reduce((groupedByDate, order) => { if (!groupedByDate[order.date]) { groupedByDate[order.date] = []; } @@ -367,8 +519,10 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { } @LogPerformance - private async computeMarketMap(dateQuery: { gte: Date; lte: Date }) { - const dataGatheringItems: IDataGatheringItem[] = this.activities + private mapToDataGatheringItems( + orders: PortfolioOrder[] + ): IDataGatheringItem[] { + return orders .map((activity) => { return { symbol: activity.SymbolProfile.symbol, @@ -379,6 +533,14 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { (gathering, i, arr) => arr.findIndex((t) => t.symbol === gathering.symbol) === i ); + } + + @LogPerformance + private async computeMarketMap(dateQuery: DateQuery): Promise<{ + [date: string]: { [symbol: string]: Big }; + }> { + const dataGatheringItems: IDataGatheringItem[] = + this.mapToDataGatheringItems(this.activities); const { values: marketSymbols } = await this.currentRateService.getValues({ dataGatheringItems, dateQuery @@ -402,6 +564,41 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator { } } - this.marketMap = marketSymbolMap; + return marketSymbolMap; + } + + @LogPerformance + private activitiesToPortfolioOrder(activities: Activity[]): PortfolioOrder[] { + return activities + .map( + ({ + date, + fee, + quantity, + SymbolProfile, + tags = [], + type, + unitPrice + }) => { + if (isAfter(date, new Date(Date.now()))) { + // Adapt date to today if activity is in future (e.g. liability) + // to include it in the interval + date = endOfDay(new Date(Date.now())); + } + + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ) + .sort((a, b) => { + return a.date?.localeCompare(b.date); + }); } } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts index 38e2b3eaf..b6089247e 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -8,6 +8,7 @@ import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { OrderService } from '../../order/order.service'; import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator'; import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; @@ -25,7 +26,8 @@ export class PortfolioCalculatorFactory { private readonly configurationService: ConfigurationService, private readonly currentRateService: CurrentRateService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly redisCacheService: RedisCacheService + private readonly redisCacheService: RedisCacheService, + private readonly orderservice: OrderService ) {} public createCalculator({ @@ -64,31 +66,37 @@ export class PortfolioCalculatorFactory { redisCacheService: this.redisCacheService }); case PerformanceCalculationType.TWR: - return new CPRPortfolioCalculator({ - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - dateRange, - useCache, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - redisCacheService: this.redisCacheService - }); + return new CPRPortfolioCalculator( + { + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }, + this.orderservice + ); case PerformanceCalculationType.CPR: - return new CPRPortfolioCalculator({ - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - dateRange, - useCache, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - redisCacheService: this.redisCacheService - }); + return new CPRPortfolioCalculator( + { + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }, + this.orderservice + ); default: throw new Error('Invalid calculation type'); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index a45801677..57d5771ed 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -65,7 +65,7 @@ export abstract class PortfolioCalculator { private startDate: Date; private transactionPoints: TransactionPoint[]; private useCache: boolean; - private userId: string; + protected userId: string; protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; public constructor({ diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 0d7d6814f..bc9bd5572 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,6 +1,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { resetHours } from '@ghostfolio/common/helper'; import { @@ -48,38 +49,12 @@ export class CurrentRateService { if (includesToday) { promises.push( - this.dataProviderService - .getQuotes({ items: dataGatheringItems, user: this.request?.user }) - .then((dataResultProvider) => { - const result: GetValueObject[] = []; - - for (const dataGatheringItem of dataGatheringItems) { - if ( - dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo - ) { - dataProviderInfos.push( - dataResultProvider[dataGatheringItem.symbol].dataProviderInfo - ); - } - - if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { - result.push({ - dataSource: dataGatheringItem.dataSource, - date: today, - marketPrice: - dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice, - symbol: dataGatheringItem.symbol - }); - } else { - quoteErrors.push({ - dataSource: dataGatheringItem.dataSource, - symbol: dataGatheringItem.symbol - }); - } - } - - return result; - }) + this.getTodayPrivate( + dataGatheringItems, + dataProviderInfos, + today, + quoteErrors + ) ); } @@ -169,6 +144,61 @@ export class CurrentRateService { return response; } + public async getToday( + dataGatheringItems: IDataGatheringItem[] + ): Promise { + const dataProviderInfos: DataProviderInfo[] = []; + const quoteErrors: UniqueAsset[] = []; + const today = resetHours(new Date()); + + return this.getTodayPrivate( + dataGatheringItems, + dataProviderInfos, + today, + quoteErrors + ); + } + + private async getTodayPrivate( + dataGatheringItems: IDataGatheringItem[], + dataProviderInfos: DataProviderInfo[], + today: Date, + quoteErrors: UniqueAsset[] + ): Promise { + return this.dataProviderService + .getQuotes({ items: dataGatheringItems, user: this.request?.user }) + .then((dataResultProvider) => { + const result: GetValueObject[] = []; + + for (const dataGatheringItem of dataGatheringItems) { + if ( + dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo + ) { + dataProviderInfos.push( + dataResultProvider[dataGatheringItem.symbol].dataProviderInfo + ); + } + + if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { + result.push({ + dataSource: dataGatheringItem.dataSource, + date: today, + marketPrice: + dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice, + symbol: dataGatheringItem.symbol + }); + } else { + quoteErrors.push({ + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }); + } + } + + return result; + }); + } + private containsToday(dates: Date[]): boolean { for (const date of dates) { if (isToday(date)) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 3da510d1f..03c7ace10 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -74,6 +74,7 @@ import { } from 'date-fns'; import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; +import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PerformanceCalculationType, @@ -1824,12 +1825,17 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(balanceInBaseCurrency) - .plus(currentValueInBaseCurrency) - .plus(valuables) - .plus(excludedAccountsAndActivities) - .minus(liabilities) - .toNumber(); + const netWorth = + portfolioCalculator instanceof CPRPortfolioCalculator + ? await (portfolioCalculator as CPRPortfolioCalculator) + .getUnfilteredNetWorth(this.getUserCurrency()) + .then((value) => value.toNumber()) + : new Big(balanceInBaseCurrency) + .plus(currentValueInBaseCurrency) + .plus(valuables) + .plus(excludedAccountsAndActivities) + .minus(liabilities) + .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate);