diff --git a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts deleted file mode 100644 index c5d73e6d6..000000000 --- a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts +++ /dev/null @@ -1,595 +0,0 @@ -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 } from '@ghostfolio/api/helper/portfolio.helper'; -import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; -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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; -import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; - -import { Inject, Logger } from '@nestjs/common'; -import { Big } from 'big.js'; -import { - addDays, - eachDayOfInterval, - endOfDay, - format, - isAfter, - isBefore, - 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 { RoaiPortfolioCalculator } from '../roai/portfolio-calculator'; - -export class CPRPortfolioCalculator extends RoaiPortfolioCalculator { - private holdings: { [date: string]: { [symbol: string]: Big } } = {}; - private holdingCurrencies: { [symbol: string]: string } = {}; - - constructor( - { - accountBalanceItems, - activities, - configurationService, - currency, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService, - userId, - filters - }: { - accountBalanceItems: HistoricalDataItem[]; - activities: Activity[]; - configurationService: ConfigurationService; - currency: string; - currentRateService: CurrentRateService; - exchangeRateDataService: ExchangeRateDataService; - portfolioSnapshotService: PortfolioSnapshotService; - redisCacheService: RedisCacheService; - filters: Filter[]; - userId: string; - }, - @Inject() - private orderService: OrderService - ) { - super({ - accountBalanceItems, - activities, - configurationService, - currency, - filters, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService, - userId - }); - } - - @LogPerformance - public async getPerformanceWithTimeWeightedReturn({ - start, - end - }: { - start: Date; - end: Date; - }): Promise<{ chart: HistoricalDataItem[] }> { - const item = await super.getPerformance({ - end, - start - }); - - const itemResult = item.chart; - const dates = itemResult.map((item) => parseDate(item.date)); - const timeWeighted = await this.getTimeWeightedChartData({ - dates - }); - - item.chart = itemResult.map((itemInt) => { - const timeWeightedItem = timeWeighted.find( - (timeWeightedItem) => timeWeightedItem.date === itemInt.date - ); - if (timeWeightedItem) { - itemInt.timeWeightedPerformance = - timeWeightedItem.netPerformanceInPercentage; - itemInt.timeWeightedPerformanceWithCurrencyEffect = - timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect; - } - - return itemInt; - }); - return item; - } - - @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.getValues({ - dataGatheringItems: this.mapToDataGatheringItems(orders), - dateQuery: { in: [end] } - }); - const endString = format(end, DATE_FORMAT); - const exchangeRates = await Promise.all( - Object.keys(holdings[endString]).map(async (holding) => { - const symbolCurrency = this.getCurrencyFromActivities(orders, holding); - const exchangeRate = - await this.exchangeRateDataService.toCurrencyAtDate( - 1, - symbolCurrency, - this.currency, - end - ); - return { symbolCurrency, exchangeRate }; - }) - ); - const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>( - (all, currency): { [currency: string]: number } => { - all[currency.symbolCurrency] ??= currency.exchangeRate; - return all; - }, - {} - ); - - const totalInvestment = await Object.keys(holdings[endString]).reduce( - (sum, holding) => { - if (!holdings[endString][holding].toNumber()) { - return sum; - } - const symbol = marketMap.values.find((m) => m.symbol === holding); - - if (symbol?.marketPrice === undefined) { - Logger.warn( - `Missing historical market data for ${holding} (${end})`, - 'PortfolioCalculator' - ); - return sum; - } else { - const symbolCurrency = this.getCurrency(holding); - const price = new Big(currencyRates[symbolCurrency]).mul( - symbol.marketPrice - ); - return sum.plus(new Big(price).mul(holdings[endString][holding])); - } - }, - new Big(0) - ); - return totalInvestment; - } - - @LogPerformance - protected async getTimeWeightedChartData({ - dates - }: { - dates?: Date[]; - }): Promise { - dates = dates.sort((a, b) => a.getTime() - b.getTime()); - const start = dates[0]; - const end = dates[dates.length - 1]; - const marketMapTask = this.computeMarketMap({ - gte: start, - lt: addDays(end, 1) - }); - const timelineHoldings = await this.getHoldings( - this.activities, - start, - end - ); - - const data: HistoricalDataItem[] = []; - const startString = format(start, DATE_FORMAT); - - data.push({ - date: startString, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - investmentValueWithCurrencyEffect: 0, - netPerformance: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - this.marketMap = await marketMapTask; - - let totalInvestment = Object.keys(timelineHoldings[startString]).reduce( - (sum, holding) => { - return sum.plus( - timelineHoldings[startString][holding].mul( - this.marketMap[startString][holding] ?? new Big(0) - ) - ); - }, - new Big(0) - ); - - let previousNetPerformanceInPercentage = new Big(0); - let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); - - for (let i = 1; i < dates.length; i++) { - const date = format(dates[i], DATE_FORMAT); - const previousDate = format(dates[i - 1], DATE_FORMAT); - const holdings = timelineHoldings[previousDate]; - let newTotalInvestment = new Big(0); - let netPerformanceInPercentage = new Big(0); - let netPerformanceInPercentageWithCurrencyEffect = new Big(0); - - for (const holding of Object.keys(holdings)) { - ({ - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - newTotalInvestment - } = await this.handleSingleHolding( - previousDate, - holding, - date, - totalInvestment, - timelineHoldings, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - newTotalInvestment - )); - } - totalInvestment = newTotalInvestment; - - previousNetPerformanceInPercentage = previousNetPerformanceInPercentage - .plus(1) - .mul(netPerformanceInPercentage.plus(1)) - .minus(1); - previousNetPerformanceInPercentageWithCurrencyEffect = - previousNetPerformanceInPercentageWithCurrencyEffect - .plus(1) - .mul(netPerformanceInPercentageWithCurrencyEffect.plus(1)) - .minus(1); - - data.push({ - date, - netPerformanceInPercentage: previousNetPerformanceInPercentage - .mul(100) - .toNumber(), - netPerformanceInPercentageWithCurrencyEffect: - previousNetPerformanceInPercentageWithCurrencyEffect - .mul(100) - .toNumber() - }); - } - - return data; - } - - @LogPerformance - protected async handleSingleHolding( - previousDate: string, - holding: string, - date: string, - totalInvestment: Big, - timelineHoldings: { [date: string]: { [symbol: string]: Big } }, - netPerformanceInPercentage: Big, - netPerformanceInPercentageWithCurrencyEffect: Big, - newTotalInvestment: Big - ) { - const previousPrice = - Object.keys(this.marketMap).indexOf(previousDate) > 0 - ? this.marketMap[previousDate][holding] - : undefined; - const priceDictionary = this.marketMap[date]; - let currentPrice = - priceDictionary !== undefined ? priceDictionary[holding] : previousPrice; - currentPrice ??= previousPrice; - const previousHolding = timelineHoldings[previousDate][holding]; - - const priceInBaseCurrency = currentPrice - ? new Big( - await this.exchangeRateDataService.toCurrencyAtDate( - currentPrice?.toNumber() ?? 0, - this.getCurrency(holding), - this.currency, - parseDate(date) - ) - ) - : new Big(0); - - if (previousHolding.eq(0)) { - return { - netPerformanceInPercentage: netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect: - netPerformanceInPercentageWithCurrencyEffect, - newTotalInvestment: newTotalInvestment.plus( - timelineHoldings[date][holding].mul(priceInBaseCurrency) - ) - }; - } - if (previousPrice === undefined || currentPrice === undefined) { - Logger.warn( - `Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`, - 'PortfolioCalculator' - ); - return { - netPerformanceInPercentage: netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect: - netPerformanceInPercentageWithCurrencyEffect, - newTotalInvestment: newTotalInvestment.plus( - timelineHoldings[date][holding].mul(priceInBaseCurrency) - ) - }; - } - const previousPriceInBaseCurrency = previousPrice - ? new Big( - await this.exchangeRateDataService.toCurrencyAtDate( - previousPrice?.toNumber() ?? 0, - this.getCurrency(holding), - this.currency, - parseDate(previousDate) - ) - ) - : new Big(0); - const portfolioWeight = totalInvestment.toNumber() - ? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment) - : new Big(0); - - netPerformanceInPercentage = netPerformanceInPercentage.plus( - currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) - ); - - netPerformanceInPercentageWithCurrencyEffect = - netPerformanceInPercentageWithCurrencyEffect.plus( - priceInBaseCurrency - .div(previousPriceInBaseCurrency) - .minus(1) - .mul(portfolioWeight) - ); - - newTotalInvestment = newTotalInvestment.plus( - timelineHoldings[date][holding].mul(priceInBaseCurrency) - ); - return { - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - newTotalInvestment - }; - } - - @LogPerformance - protected getCurrency(symbol: string) { - return this.getCurrencyFromActivities(this.activities, symbol); - } - - @LogPerformance - protected getCurrencyFromActivities( - activities: PortfolioOrder[], - symbol: string - ) { - if (!this.holdingCurrencies[symbol]) { - this.holdingCurrencies[symbol] = activities.find( - (a) => a.SymbolProfile.symbol === symbol - ).SymbolProfile.currency; - } - - return this.holdingCurrencies[symbol]; - } - - @LogPerformance - protected async getHoldings( - activities: PortfolioOrder[], - start: Date, - end: Date - ) { - if ( - this.holdings && - Object.keys(this.holdings).some((h) => - isAfter(parseDate(h), subDays(end, 1)) - ) && - Object.keys(this.holdings).some((h) => - isBefore(parseDate(h), addDays(start, 1)) - ) - ) { - return this.holdings; - } - - this.computeHoldings(activities, start, end); - return this.holdings; - } - - @LogPerformance - protected 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(); - const dates = eachDayOfInterval({ start, end }, { step: 1 }) - .map((date) => { - return resetHours(date); - }) - .sort((a, b) => a.getTime() - b.getTime()); - const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; - - this.calculateInitialHoldings(investmentByDate, start, currentHoldings); - - for (let i = 1; i < dates.length; i++) { - const dateString = format(dates[i], DATE_FORMAT); - const previousDateString = format(dates[i - 1], DATE_FORMAT); - if (transactionDates.some((d) => d === dateString)) { - const holdings = { ...currentHoldings[previousDateString] }; - investmentByDate[dateString].forEach((trade) => { - holdings[trade.SymbolProfile.symbol] ??= new Big(0); - holdings[trade.SymbolProfile.symbol] = holdings[ - trade.SymbolProfile.symbol - ].plus(trade.quantity.mul(getFactor(trade.type))); - }); - currentHoldings[dateString] = holdings; - } else { - currentHoldings[dateString] = currentHoldings[previousDateString]; - } - } - - this.holdings = currentHoldings; - } - - @LogPerformance - protected calculateInitialHoldings( - investmentByDate: { [date: string]: PortfolioOrder[] }, - start: Date, - currentHoldings: { [date: string]: { [symbol: string]: Big } } - ) { - const preRangeTrades = Object.keys(investmentByDate) - .filter((date) => resetHours(new Date(date)) <= start) - .map((date) => investmentByDate[date]) - .reduce((a, b) => a.concat(b), []) - .reduce((groupBySymbol, trade) => { - if (!groupBySymbol[trade.SymbolProfile.symbol]) { - groupBySymbol[trade.SymbolProfile.symbol] = []; - } - - groupBySymbol[trade.SymbolProfile.symbol].push(trade); - - return groupBySymbol; - }, {}); - - currentHoldings[format(start, DATE_FORMAT)] = {}; - - for (const symbol of Object.keys(preRangeTrades)) { - const trades: PortfolioOrder[] = preRangeTrades[symbol]; - const startQuantity = trades.reduce((sum, trade) => { - return sum.plus(trade.quantity.mul(getFactor(trade.type))); - }, new Big(0)); - currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity; - } - } - - @LogPerformance - protected getInvestmentByDate(activities: PortfolioOrder[]): { - [date: string]: PortfolioOrder[]; - } { - return activities.reduce((groupedByDate, order) => { - if (!groupedByDate[order.date]) { - groupedByDate[order.date] = []; - } - - groupedByDate[order.date].push(order); - - return groupedByDate; - }, {}); - } - - @LogPerformance - protected mapToDataGatheringItems( - orders: PortfolioOrder[] - ): IDataGatheringItem[] { - return orders - .map((activity) => { - return { - symbol: activity.SymbolProfile.symbol, - dataSource: activity.SymbolProfile.dataSource - }; - }) - .filter( - (gathering, i, arr) => - arr.findIndex((t) => t.symbol === gathering.symbol) === i - ); - } - - @LogPerformance - protected 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 - }); - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - for (const marketSymbol of marketSymbols) { - const date = format(marketSymbol.date, DATE_FORMAT); - - if (!marketSymbolMap[date]) { - marketSymbolMap[date] = {}; - } - - if (marketSymbol.marketPrice) { - marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - - return marketSymbolMap; - } - - @LogPerformance - protected 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 27f4ead2f..fe4b04cff 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -10,19 +10,13 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Injectable } from '@nestjs/common'; +import { OrderService } from '../../order/order.service'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; import { RoiPortfolioCalculator } from './roi/portfolio-calculator'; import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; -export enum PerformanceCalculationType { - MWR = 'MWR', // Money-Weighted Rate of Return - ROAI = 'ROAI', // Return on Average Investment - TWR = 'TWR', // Time-Weighted Rate of Return - CPR = 'CPR' // Constant Portfolio Rate of Return -} - @Injectable() export class PortfolioCalculatorFactory { public constructor( @@ -30,7 +24,8 @@ export class PortfolioCalculatorFactory { private readonly currentRateService: CurrentRateService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly portfolioSnapshotService: PortfolioSnapshotService, - private readonly redisCacheService: RedisCacheService + private readonly redisCacheService: RedisCacheService, + private readonly orderService: OrderService ) {} @LogPerformance @@ -61,7 +56,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.ROAI: @@ -75,7 +71,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.ROI: @@ -89,7 +86,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.TWR: @@ -103,19 +101,7 @@ export class PortfolioCalculatorFactory { exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, redisCacheService: this.redisCacheService, - filters - }); - case PerformanceCalculationType.CPR: - return new RoaiPortfolioCalculator({ - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService, + orderService: this.orderService, filters }); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 9bfd320f9..629c4b33e 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -41,6 +41,7 @@ import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { + addDays, differenceInDays, eachDayOfInterval, endOfDay, @@ -52,6 +53,8 @@ import { } from 'date-fns'; import { isNumber, sortBy, sum, uniqBy } from 'lodash'; +import { OrderService } from '../../order/order.service'; + export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; @@ -64,6 +67,7 @@ export abstract class PortfolioCalculator { private dataProviderInfos: DataProviderInfo[]; private endDate: Date; protected exchangeRateDataService: ExchangeRateDataService; + protected orderService: OrderService; private filters: Filter[]; private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; @@ -73,6 +77,8 @@ export abstract class PortfolioCalculator { private transactionPoints: TransactionPoint[]; protected userId: string; protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; + private holdings: { [date: string]: { [symbol: string]: Big } } = {}; + private holdingCurrencies: { [symbol: string]: string } = {}; public constructor({ accountBalanceItems, @@ -84,7 +90,8 @@ export abstract class PortfolioCalculator { filters, portfolioSnapshotService, redisCacheService, - userId + userId, + orderService }: { accountBalanceItems: HistoricalDataItem[]; activities: Activity[]; @@ -96,6 +103,7 @@ export abstract class PortfolioCalculator { portfolioSnapshotService: PortfolioSnapshotService; redisCacheService: RedisCacheService; userId: string; + orderService: OrderService; }) { this.accountBalanceItems = accountBalanceItems; this.configurationService = configurationService; @@ -103,6 +111,7 @@ export abstract class PortfolioCalculator { this.currentRateService = currentRateService; this.exchangeRateDataService = exchangeRateDataService; this.filters = filters; + this.orderService = orderService; let dateOfFirstActivity = new Date(); @@ -613,9 +622,77 @@ export abstract class PortfolioCalculator { }; } - protected abstract getPerformanceCalculationType(): PerformanceCalculationType; + @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.getValues({ + dataGatheringItems: this.mapToDataGatheringItems(orders), + dateQuery: { in: [end] } + }); + const endString = format(end, DATE_FORMAT); + const exchangeRates = await Promise.all( + Object.keys(holdings[endString]).map(async (holding) => { + const symbolCurrency = this.getCurrencyFromActivities(orders, holding); + const exchangeRate = + await this.exchangeRateDataService.toCurrencyAtDate( + 1, + symbolCurrency, + this.currency, + end + ); + return { symbolCurrency, exchangeRate }; + }) + ); + const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>( + (all, currency): { [currency: string]: number } => { + all[currency.symbolCurrency] ??= currency.exchangeRate; + return all; + }, + {} + ); - protected abstract getPerformanceCalculationType(): PerformanceCalculationType; + const totalInvestment = await Object.keys(holdings[endString]).reduce( + (sum, holding) => { + if (!holdings[endString][holding].toNumber()) { + return sum; + } + const symbol = marketMap.values.find((m) => m.symbol === holding); + + if (symbol?.marketPrice === undefined) { + Logger.warn( + `Missing historical market data for ${holding} (${end})`, + 'PortfolioCalculator' + ); + return sum; + } else { + const symbolCurrency = this.getCurrency(holding); + const price = new Big(currencyRates[symbolCurrency]).mul( + symbol.marketPrice + ); + return sum.plus(new Big(price).mul(holdings[endString][holding])); + } + }, + new Big(0) + ); + return totalInvestment; + } @LogPerformance public getDataProviderInfos() { @@ -807,6 +884,25 @@ export abstract class PortfolioCalculator { return this.snapshot; } + @LogPerformance + protected getCurrency(symbol: string) { + return this.getCurrencyFromActivities(this.activities, symbol); + } + + @LogPerformance + protected getCurrencyFromActivities( + activities: PortfolioOrder[], + symbol: string + ) { + if (!this.holdingCurrencies[symbol]) { + this.holdingCurrencies[symbol] = activities.find( + (a) => a.SymbolProfile.symbol === symbol + ).SymbolProfile.currency; + } + + return this.holdingCurrencies[symbol]; + } + @LogPerformance protected computeTransactionPoints() { this.transactionPoints = []; @@ -1030,6 +1126,173 @@ export abstract class PortfolioCalculator { } } + @LogPerformance + protected 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); + }); + } + + @LogPerformance + protected async getHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { + if ( + this.holdings && + Object.keys(this.holdings).some((h) => + isAfter(parseDate(h), subDays(end, 1)) + ) && + Object.keys(this.holdings).some((h) => + isBefore(parseDate(h), addDays(start, 1)) + ) + ) { + return this.holdings; + } + + this.computeHoldings(activities, start, end); + return this.holdings; + } + + @LogPerformance + protected async computeHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { + const investmentByDate = this.getInvestmentByDate(activities); + this.calculateHoldings(investmentByDate, start, end); + } + + @LogPerformance + protected calculateInitialHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + currentHoldings: { [date: string]: { [symbol: string]: Big } } + ) { + const preRangeTrades = Object.keys(investmentByDate) + .filter((date) => resetHours(new Date(date)) <= start) + .map((date) => investmentByDate[date]) + .reduce((a, b) => a.concat(b), []) + .reduce((groupBySymbol, trade) => { + if (!groupBySymbol[trade.SymbolProfile.symbol]) { + groupBySymbol[trade.SymbolProfile.symbol] = []; + } + + groupBySymbol[trade.SymbolProfile.symbol].push(trade); + + return groupBySymbol; + }, {}); + + currentHoldings[format(start, DATE_FORMAT)] = {}; + + for (const symbol of Object.keys(preRangeTrades)) { + const trades: PortfolioOrder[] = preRangeTrades[symbol]; + const startQuantity = trades.reduce((sum, trade) => { + return sum.plus(trade.quantity.mul(getFactor(trade.type))); + }, new Big(0)); + currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity; + } + } + + @LogPerformance + protected getInvestmentByDate(activities: PortfolioOrder[]): { + [date: string]: PortfolioOrder[]; + } { + return activities.reduce((groupedByDate, order) => { + if (!groupedByDate[order.date]) { + groupedByDate[order.date] = []; + } + + groupedByDate[order.date].push(order); + + return groupedByDate; + }, {}); + } + + @LogPerformance + protected mapToDataGatheringItems( + orders: PortfolioOrder[] + ): IDataGatheringItem[] { + return orders + .map((activity) => { + return { + symbol: activity.SymbolProfile.symbol, + dataSource: activity.SymbolProfile.dataSource + }; + }) + .filter( + (gathering, i, arr) => + arr.findIndex((t) => t.symbol === gathering.symbol) === i + ); + } + + private calculateHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + end: Date + ) { + const transactionDates = Object.keys(investmentByDate).sort(); + const dates = eachDayOfInterval({ start, end }, { step: 1 }) + .map((date) => { + return resetHours(date); + }) + .sort((a, b) => a.getTime() - b.getTime()); + const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; + + this.calculateInitialHoldings(investmentByDate, start, currentHoldings); + + for (let i = 1; i < dates.length; i++) { + const dateString = format(dates[i], DATE_FORMAT); + const previousDateString = format(dates[i - 1], DATE_FORMAT); + if (transactionDates.some((d) => d === dateString)) { + const holdings = { ...currentHoldings[previousDateString] }; + investmentByDate[dateString].forEach((trade) => { + holdings[trade.SymbolProfile.symbol] ??= new Big(0); + holdings[trade.SymbolProfile.symbol] = holdings[ + trade.SymbolProfile.symbol + ].plus(trade.quantity.mul(getFactor(trade.type))); + }); + currentHoldings[dateString] = holdings; + } else { + currentHoldings[dateString] = currentHoldings[previousDateString]; + } + } + + this.holdings = currentHoldings; + } + private getChartDateMap({ endDate, startDate, @@ -1117,4 +1380,6 @@ export abstract class PortfolioCalculator { protected abstract calculateOverallPerformance( positions: TimelinePosition[] ): PortfolioSnapshot; + + protected abstract getPerformanceCalculationType(): PerformanceCalculationType; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index c00db4b40..92371fb26 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -506,8 +506,7 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccounts = false, - @Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false + @Query('withExcludedAccounts') withExcludedAccounts = false ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, @@ -522,8 +521,7 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - userId: this.request.user.id, - calculateTimeWeightedPerformance + userId: this.request.user.id }); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index fa4c40994..2fab5e5ec 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -86,7 +86,6 @@ import { } from 'date-fns'; import { isEmpty, uniqBy } from 'lodash'; -import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; @@ -1102,15 +1101,13 @@ export class PortfolioService { dateRange = 'max', filters, impersonationId, - userId, - calculateTimeWeightedPerformance = false + userId }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; - calculateTimeWeightedPerformance?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1158,11 +1155,7 @@ export class PortfolioService { const { endDate, startDate } = getIntervalFromDateRange(dateRange); const range = { end: endDate, start: startDate }; - const { chart } = await (calculateTimeWeightedPerformance - ? ( - portfolioCalculator as CPRPortfolioCalculator - ).getPerformanceWithTimeWeightedReturn(range) - : portfolioCalculator.getPerformance(range)); + const { chart } = await portfolioCalculator.getPerformance(range); const { netPerformance, @@ -1932,17 +1925,9 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .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 netWorth = await portfolioCalculator + .getUnfilteredNetWorth(this.getUserCurrency()) + .then((value) => value.toNumber()); const daysInMarket = differenceInDays(new Date(), firstOrderDate); diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index 0e1936399..d65d50fb7 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -210,9 +210,8 @@ export class DataGatheringProcessor { name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME }) public async gatherMissingHistoricalMarketData(job: Job) { + const { dataSource, date, symbol } = job.data; try { - const { dataSource, date, symbol } = job.data; - Logger.log( `Historical market data gathering for missing values has been started for ${symbol} (${dataSource}) at ${format( date, diff --git a/package-lock.json b/package-lock.json index 1dfa43d7d..d1adff321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13231,6 +13231,18 @@ "dev": true, "license": "MIT" }, + "node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", @@ -17238,6 +17250,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -19803,61 +19827,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", - "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", - "dev": true, - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/ignore": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", - "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/globby/node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/good-listener": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",