From 97ba28fe1c243ed013c6636ec980502cc31a5917 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 10 May 2024 11:22:28 +0200 Subject: [PATCH] Readded TimeWeighted Performance --- .../portfolio-calculator.ts | 298 ++++++++++++++++++ .../portfolio-calculator.factory.ts | 17 +- .../calculator/portfolio-calculator.ts | 7 +- .../src/app/portfolio/portfolio.service.ts | 54 +++- .../historical-data-item.interface.ts | 1 + 5 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts diff --git a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts new file mode 100644 index 000000000..87b28ab85 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts @@ -0,0 +1,298 @@ +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { parseDate, resetHours } from '@ghostfolio/common/helper'; +import { + HistoricalDataItem, + SymbolMetrics, + UniqueAsset +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; + +import { Big } from 'big.js'; +import { addDays, eachDayOfInterval } from 'date-fns'; + +import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; +import { TWRPortfolioCalculator } from '../twr/portfolio-calculator'; + +export class CPRPortfolioCalculator extends TWRPortfolioCalculator { + private holdings: { [date: string]: { [symbol: string]: Big } } = {}; + private holdingCurrencies: { [symbol: string]: string } = {}; + + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + return super.calculateOverallPerformance(positions); + } + + protected getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode = false, + marketSymbolMap, + start, + step = 1, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + isChartMode?: boolean; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & UniqueAsset): SymbolMetrics { + return super.getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode, + marketSymbolMap, + start, + step, + symbol + }); + } + + public override async getChartData({ + end = new Date(Date.now()), + start, + step = 1 + }: { + end?: Date; + start: Date; + step?: number; + }): Promise { + const timelineHoldings = this.getHoldings(start, end); + const calculationDates = Object.keys(timelineHoldings) + .filter((date) => { + let parsed = parseDate(date); + parsed >= start && parsed <= end; + }) + .sort(); + let data: HistoricalDataItem[] = []; + + data.push({ + date: start.toDateString(), + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + let totalInvestment = Object.keys( + timelineHoldings[start.toDateString()] + ).reduce((sum, holding) => { + return sum.plus( + timelineHoldings[start.toDateString()][holding].mul( + this.marketMap[start.toDateString()][holding] + ) + ); + }, new Big(0)); + + let previousNetPerformanceInPercentage = new Big(0); + let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); + + for (let i = 1; i < calculationDates.length; i++) { + const date = calculationDates[i]; + const previousDate = calculationDates[i - 1]; + 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.mul( + netPerformanceInPercentage.plus(1) + ); + previousNetPerformanceInPercentageWithCurrencyEffect = + previousNetPerformanceInPercentageWithCurrencyEffect.mul( + netPerformanceInPercentageWithCurrencyEffect.plus(1) + ); + + data.push({ + date, + netPerformanceInPercentage: + previousNetPerformanceInPercentage.toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + previousNetPerformanceInPercentageWithCurrencyEffect.toNumber() + }); + } + + return data; + } + + private async handleSingleHolding( + previousDate: string, + holding: string, + date: string, + totalInvestment, + timelineHoldings: { [date: string]: { [symbol: string]: Big } }, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + ) { + const previousPrice = this.marketMap[previousDate][holding]; + const currentPrice = this.marketMap[date][holding]; + const previousPriceInBaseCurrency = + await this.exchangeRateDataService.toCurrencyAtDate( + previousPrice.toNumber(), + this.getCurrency(holding), + this.currency, + parseDate(previousDate) + ); + const portfolioWeight = totalInvestment + ? timelineHoldings[previousDate][holding] + .mul(previousPriceInBaseCurrency) + .div(totalInvestment) + : 0; + + netPerformanceInPercentage = netPerformanceInPercentage.plus( + currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) + ); + + const priceInBaseCurrency = + await this.exchangeRateDataService.toCurrencyAtDate( + currentPrice.toNumber(), + this.getCurrency(holding), + this.currency, + parseDate(date) + ); + netPerformanceInPercentageWithCurrencyEffect = + netPerformanceInPercentageWithCurrencyEffect.plus( + new Big(priceInBaseCurrency) + .div(new Big(previousPriceInBaseCurrency)) + .minus(1) + .mul(portfolioWeight) + ); + + newTotalInvestment = newTotalInvestment.plus( + timelineHoldings[date][holding].mul(priceInBaseCurrency) + ); + return { + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + }; + } + + private getCurrency(symbol: string) { + if (!this.holdingCurrencies[symbol]) { + this.holdingCurrencies[symbol] = this.activities.find( + (a) => a.SymbolProfile.symbol === symbol + ).SymbolProfile.currency; + } + + return this.holdingCurrencies[symbol]; + } + + private getHoldings(start: Date, end: Date) { + if ( + this.holdings && + Object.keys(this.holdings).some((h) => parseDate(h) >= end) && + Object.keys(this.holdings).some((h) => parseDate(h) <= start) + ) { + return this.holdings; + } + + this.computeHoldings(start, end); + return this.holdings; + } + + private computeHoldings(start: Date, end: Date) { + const investmentByDate = this.getInvestmentByDate(); + const transactionDates = Object.keys(investmentByDate).sort(); + let dates = eachDayOfInterval({ start, end }, { step: 1 }) + .map((date) => { + return resetHours(date); + }) + .sort((a, b) => a.getTime() - b.getTime()); + let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; + + this.calculateInitialHoldings(investmentByDate, start, currentHoldings); + + for (let i = 1; i < dates.length; i++) { + const date = dates[i]; + const previousDate = dates[i - 1]; + if (transactionDates.some((d) => d === date.toDateString())) { + let holdings = { ...currentHoldings[previousDate.toDateString()] }; + investmentByDate[date.toDateString()].forEach((trade) => { + holdings[trade.SymbolProfile.symbol] = holdings[ + trade.SymbolProfile.symbol + ].plus(trade.quantity.mul(getFactor(trade.type))); + }); + currentHoldings[date.toDateString()] = holdings; + } else { + currentHoldings[date.toDateString()] = + currentHoldings[previousDate.toDateString()]; + } + } + + this.holdings = currentHoldings; + } + + private 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; + }, {}); + + for (const symbol of Object.keys(preRangeTrades)) { + const trades: PortfolioOrder[] = preRangeTrades[symbol]; + let startQuantity = trades.reduce((sum, trade) => { + return sum.plus(trade.quantity.mul(getFactor(trade.type))); + }, new Big(0)); + currentHoldings[start.toDateString()][symbol] = startQuantity; + } + } + + private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } { + return this.activities.reduce((groupedByDate, order) => { + if (!groupedByDate[order.date]) { + groupedByDate[order.date] = []; + } + + groupedByDate[order.date].push(order); + + return groupedByDate; + }, {}); + } +} 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 762415d1e..89181ea12 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -8,13 +8,15 @@ import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator'; import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; export enum PerformanceCalculationType { MWR = 'MWR', // Money-Weighted Rate of Return - TWR = 'TWR' // Time-Weighted Rate of Return + TWR = 'TWR', // Time-Weighted Rate of Return + CPR = 'CPR' // Constant Portfolio Rate of Return } @Injectable() @@ -74,6 +76,19 @@ export class PortfolioCalculatorFactory { exchangeRateDataService: this.exchangeRateDataService, redisCacheService: this.redisCacheService }); + case PerformanceCalculationType.CPR: + return new CPRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); 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 52ce1a6e9..274f4cffa 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -53,12 +53,12 @@ export abstract class PortfolioCalculator { protected activities: PortfolioOrder[]; private configurationService: ConfigurationService; - private currency: string; + protected currency: string; private currentRateService: CurrentRateService; private dataProviderInfos: DataProviderInfo[]; private dateRange: DateRange; private endDate: Date; - private exchangeRateDataService: ExchangeRateDataService; + protected exchangeRateDataService: ExchangeRateDataService; private redisCacheService: RedisCacheService; private snapshot: PortfolioSnapshot; private snapshotPromise: Promise; @@ -66,6 +66,7 @@ export abstract class PortfolioCalculator { private transactionPoints: TransactionPoint[]; private useCache: boolean; private userId: string; + protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; public constructor({ accountBalanceItems, @@ -288,6 +289,8 @@ export abstract class PortfolioCalculator { } } + this.marketMap = marketSymbolMap; + const endDateString = format(endDate, DATE_FORMAT); if (firstIndex > 0) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index feea4c67e..81601dbb3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -740,7 +740,9 @@ export class PortfolioService { const portfolioCalculator = this.calculatorFactory.createCalculator({ userId, activities: orders.filter((order) => { - return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes( + order.type + ); }), calculationType: PerformanceCalculationType.TWR, currency: userCurrency, @@ -1279,10 +1281,20 @@ export class PortfolioService { let currentNetWorth = 0; - const items = await portfolioCalculator.getChart({ + let items = await portfolioCalculator.getChart({ dateRange }); + items = await this.calculatedTimeWeightedPerformance( + calculateTimeWeightedPerformance, + activities, + dateRange, + userId, + userCurrency, + filters, + items + ); + const itemOfToday = items.find(({ date }) => { return date === format(new Date(), DATE_FORMAT); }); @@ -1330,6 +1342,44 @@ export class PortfolioService { }; } + private async calculatedTimeWeightedPerformance( + calculateTimeWeightedPerformance: boolean, + activities: Activity[], + dateRange: string, + userId: string, + userCurrency: string, + filters: Filter[], + items: HistoricalDataItem[] + ) { + if (calculateTimeWeightedPerformance) { + const portfolioCalculatorCPR = this.calculatorFactory.createCalculator({ + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.CPR, + currency: userCurrency, + hasFilters: filters?.length > 0, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures + }); + let timeWeightedInvestmentItems = await portfolioCalculatorCPR.getChart({ + dateRange + }); + + items = items.map((item) => { + let matchingItem = timeWeightedInvestmentItems.find( + (timeWeightedInvestmentItem) => + timeWeightedInvestmentItem.date === item.date + ); + item.timeWeightedPerformance = matchingItem.netPerformanceInPercentage; + item.timeWeightedPerformanceWithCurrencyEffect = + matchingItem.netPerformanceInPercentageWithCurrencyEffect; + return item; + }); + } + return items; + } + @LogPerformance public async getReport(impersonationId: string): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); 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 a36206011..7f785e509 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -17,5 +17,6 @@ export interface HistoricalDataItem { value?: number; valueInPercentage?: number; timeWeightedPerformance?: number; + timeWeightedPerformanceWithCurrencyEffect?: number; valueWithCurrencyEffect?: number; }