From 371ce7ee93226e6e07060aa34d0a259654e41e6c Mon Sep 17 00:00:00 2001 From: Daniel Devaud Date: Sun, 17 Dec 2023 15:44:43 +0100 Subject: [PATCH 1/4] Added Timeweighted performance + huge refactoring --- .../src/app/portfolio/portfolio-calculator.ts | 1476 ++++++++++++----- .../src/app/portfolio/portfolio.controller.ts | 6 +- .../src/app/portfolio/portfolio.service.ts | 14 +- 3 files changed, 1115 insertions(+), 381 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 16b41136c..263992bfa 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -85,52 +85,13 @@ export class PortfolioCalculator { const factor = this.getFactor(order.type); const unitPrice = new Big(order.unitPrice); - if (oldAccumulatedSymbol) { - const newQuantity = order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity); - - let investment = new Big(0); - - if (newQuantity.gt(0)) { - if (order.type === 'BUY' || order.type === 'STAKE') { - investment = oldAccumulatedSymbol.investment.plus( - order.quantity.mul(unitPrice) - ); - } else if (order.type === 'SELL') { - const averagePrice = oldAccumulatedSymbol.investment.div( - oldAccumulatedSymbol.quantity - ); - investment = oldAccumulatedSymbol.investment.minus( - order.quantity.mul(averagePrice) - ); - } - } - - currentTransactionPointItem = { - investment, - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee.plus(oldAccumulatedSymbol.fee), - firstBuyDate: oldAccumulatedSymbol.firstBuyDate, - quantity: newQuantity, - symbol: order.symbol, - tags: order.tags, - transactionCount: oldAccumulatedSymbol.transactionCount + 1 - }; - } else { - currentTransactionPointItem = { - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee, - firstBuyDate: order.date, - investment: unitPrice.mul(order.quantity).mul(factor), - quantity: order.quantity.mul(factor), - symbol: order.symbol, - tags: order.tags, - transactionCount: 1 - }; - } + currentTransactionPointItem = this.getCurrentTransactionPointItem( + oldAccumulatedSymbol, + order, + factor, + unitPrice, + currentTransactionPointItem + ); symbols[order.symbol] = currentTransactionPointItem; @@ -153,6 +114,79 @@ export class PortfolioCalculator { } } + private getCurrentTransactionPointItem( + oldAccumulatedSymbol: TransactionPointSymbol, + order: PortfolioOrder, + factor: number, + unitPrice: Big, + currentTransactionPointItem: TransactionPointSymbol + ) { + if (oldAccumulatedSymbol) { + currentTransactionPointItem = this.handleSubsequentTransactions( + order, + factor, + oldAccumulatedSymbol, + unitPrice, + currentTransactionPointItem + ); + } else { + currentTransactionPointItem = { + currency: order.currency, + dataSource: order.dataSource, + fee: order.fee, + firstBuyDate: order.date, + investment: unitPrice.mul(order.quantity).mul(factor), + quantity: order.quantity.mul(factor), + symbol: order.symbol, + tags: order.tags, + transactionCount: 1 + }; + } + return currentTransactionPointItem; + } + + private handleSubsequentTransactions( + order: PortfolioOrder, + factor: number, + oldAccumulatedSymbol: TransactionPointSymbol, + unitPrice: Big, + currentTransactionPointItem: TransactionPointSymbol + ) { + const newQuantity = order.quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + + let investment = new Big(0); + + if (newQuantity.gt(0)) { + if (order.type === 'BUY' || order.type === 'STAKE') { + investment = oldAccumulatedSymbol.investment.plus( + order.quantity.mul(unitPrice) + ); + } else if (order.type === 'SELL') { + const averagePrice = oldAccumulatedSymbol.investment.div( + oldAccumulatedSymbol.quantity + ); + investment = oldAccumulatedSymbol.investment.minus( + order.quantity.mul(averagePrice) + ); + } + } + + currentTransactionPointItem = { + investment, + currency: order.currency, + dataSource: order.dataSource, + fee: order.fee.plus(oldAccumulatedSymbol.fee), + firstBuyDate: oldAccumulatedSymbol.firstBuyDate, + quantity: newQuantity, + symbol: order.symbol, + tags: order.tags, + transactionCount: oldAccumulatedSymbol.transactionCount + 1 + }; + return currentTransactionPointItem; + } + public getAnnualizedPerformancePercent({ daysInMarket, netPerformancePercent @@ -178,7 +212,12 @@ export class PortfolioCalculator { this.transactionPoints = transactionPoints; } - public async getChartData(start: Date, end = new Date(Date.now()), step = 1) { + public async getChartData( + start: Date, + end = new Date(Date.now()), + step = 1, + calculateTimeWeightedPerformance = false + ) { const symbols: { [symbol: string]: boolean } = {}; const transactionPointsBeforeEndDate = @@ -191,35 +230,33 @@ export class PortfolioCalculator { const dataGatheringItems: IDataGatheringItem[] = []; const firstIndex = transactionPointsBeforeEndDate.length; - let day = start; - - while (isBefore(day, end)) { - dates.push(resetHours(day)); - day = addDays(day, step); - } - - if (!isSameDay(last(dates), end)) { - dates.push(resetHours(end)); - } - - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { - dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol - }); - currencies[item.symbol] = item.currency; - symbols[item.symbol] = true; - } + this.pushDataGatheringsSymbols( + transactionPointsBeforeEndDate, + firstIndex, + dataGatheringItems, + currencies, + symbols + ); + this.getRelevantStartAndEndDates(start, end, dates, step); + const dataGartheringDates = [ + ...dates, + ...this.orders.map((o) => { + let dateParsed = Date.parse(o.date); + if (isBefore(dateParsed, end) && isAfter(dateParsed, start)) { + let date = new Date(dateParsed); + if (dates.indexOf(date) === -1) { + return date; + } + } + }) + ]; const { dataProviderInfos, values: marketSymbols } = - await this.currentRateService.getValues({ + await this.getInformationFromCurrentRateService( currencies, dataGatheringItems, - dateQuery: { - in: dates - }, - userCurrency: this.currency - }); + dataGartheringDates + ); this.dataProviderInfos = dataProviderInfos; @@ -227,42 +264,143 @@ export class PortfolioCalculator { [date: string]: { [symbol: string]: Big }; } = {}; - for (const marketSymbol of marketSymbols) { - const dateString = format(marketSymbol.date, DATE_FORMAT); - if (!marketSymbolMap[dateString]) { - marketSymbolMap[dateString] = {}; + this.populateMarketSymbolMap(marketSymbols, marketSymbolMap); + + const valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + investmentValues: { [date: string]: Big }; + maxInvestmentValues: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesPercentage: { [date: string]: Big }; + }; + } = {}; + + this.populateSymbolMetrics( + symbols, + end, + marketSymbolMap, + start, + step, + valuesBySymbol + ); + + return dates.map((date: Date, index: number, dates: Date[]) => { + let previousDate: Date = index > 0 ? dates[index - 1] : null; + return this.calculatePerformance( + date, + previousDate, + valuesBySymbol, + calculateTimeWeightedPerformance + ); + }); + } + + private calculatePerformance( + date: Date, + previousDate: Date, + valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + investmentValues: { [date: string]: Big }; + maxInvestmentValues: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesPercentage: { [date: string]: Big }; + }; + }, + calculateTimeWeightedPerformance: boolean + ) { + const dateString = format(date, DATE_FORMAT); + const previousDateString = previousDate + ? format(previousDate, DATE_FORMAT) + : null; + let totalCurrentValue = new Big(0); + let totalInvestmentValue = new Big(0); + let maxTotalInvestmentValue = new Big(0); + let totalNetPerformanceValue = new Big(0); + let previousTotalInvestmentValue = new Big(0); + let timeWeightedPerformance = new Big(0); + + if (calculateTimeWeightedPerformance && previousDateString) { + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; + previousTotalInvestmentValue = previousTotalInvestmentValue.plus( + symbolValues.currentValues?.[previousDateString] ?? new Big(0) + ); } - if (marketSymbol.marketPriceInBaseCurrency) { - marketSymbolMap[dateString][marketSymbol.symbol] = new Big( - marketSymbol.marketPriceInBaseCurrency + } + + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; + const symbolCurrentValues = + symbolValues.currentValues?.[dateString] ?? new Big(0); + + totalCurrentValue = totalCurrentValue.plus(symbolCurrentValues); + totalInvestmentValue = totalInvestmentValue.plus( + symbolValues.investmentValues?.[dateString] ?? new Big(0) + ); + maxTotalInvestmentValue = maxTotalInvestmentValue.plus( + symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0) + ); + totalNetPerformanceValue = totalNetPerformanceValue.plus( + symbolValues.netPerformanceValues?.[dateString] ?? new Big(0) + ); + + if ( + calculateTimeWeightedPerformance && + previousTotalInvestmentValue.toNumber() && + symbolValues.netPerformanceValuesPercentage + ) { + const previousValue = + symbolValues.currentValues?.[previousDateString] ?? new Big(0); + const netPerformance = + symbolValues.netPerformanceValuesPercentage?.[dateString] ?? + new Big(0); + timeWeightedPerformance = timeWeightedPerformance.plus( + previousValue.div(previousTotalInvestmentValue).mul(netPerformance) ); } } + const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(maxTotalInvestmentValue) + .mul(100) + .toNumber(); - const valuesByDate: { - [date: string]: { - maxTotalInvestmentValue: Big; - totalCurrentValue: Big; - totalInvestmentValue: Big; - totalNetPerformanceValue: Big; - }; - } = {}; + return { + date: dateString, + netPerformanceInPercentage, + netPerformance: totalNetPerformanceValue.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + value: totalCurrentValue.toNumber(), + timeWeightedPerformance: timeWeightedPerformance.toNumber() + }; + } - const valuesBySymbol: { + private populateSymbolMetrics( + symbols: { [symbol: string]: boolean }, + end: Date, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + start: Date, + step: number, + valuesBySymbol: { [symbol: string]: { currentValues: { [date: string]: Big }; investmentValues: { [date: string]: Big }; maxInvestmentValues: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesPercentage: { [date: string]: Big }; }; - } = {}; - + } + ) { for (const symbol of Object.keys(symbols)) { const { currentValues, investmentValues, maxInvestmentValues, - netPerformanceValues + netPerformanceValues, + netPerformanceValuesPercentage } = this.getSymbolMetrics({ end, marketSymbolMap, @@ -276,50 +414,82 @@ export class PortfolioCalculator { currentValues, investmentValues, maxInvestmentValues, - netPerformanceValues + netPerformanceValues, + netPerformanceValuesPercentage }; } + } - return dates.map((date) => { - const dateString = format(date, DATE_FORMAT); - let totalCurrentValue = new Big(0); - let totalInvestmentValue = new Big(0); - let maxTotalInvestmentValue = new Big(0); - let totalNetPerformanceValue = new Big(0); - - for (const symbol of Object.keys(valuesBySymbol)) { - const symbolValues = valuesBySymbol[symbol]; - - totalCurrentValue = totalCurrentValue.plus( - symbolValues.currentValues?.[dateString] ?? new Big(0) - ); - totalInvestmentValue = totalInvestmentValue.plus( - symbolValues.investmentValues?.[dateString] ?? new Big(0) - ); - maxTotalInvestmentValue = maxTotalInvestmentValue.plus( - symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0) - ); - totalNetPerformanceValue = totalNetPerformanceValue.plus( - symbolValues.netPerformanceValues?.[dateString] ?? new Big(0) + private populateMarketSymbolMap( + marketSymbols: GetValueObject[], + marketSymbolMap: { [date: string]: { [symbol: string]: Big } } + ) { + for (const marketSymbol of marketSymbols) { + const dateString = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[dateString]) { + marketSymbolMap[dateString] = {}; + } + if (marketSymbol.marketPriceInBaseCurrency) { + marketSymbolMap[dateString][marketSymbol.symbol] = new Big( + marketSymbol.marketPriceInBaseCurrency ); } - const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(maxTotalInvestmentValue) - .mul(100) - .toNumber(); + } + } - return { - date: dateString, - netPerformanceInPercentage, - netPerformance: totalNetPerformanceValue.toNumber(), - totalInvestment: totalInvestmentValue.toNumber(), - value: totalCurrentValue.toNumber() - }; + private async getInformationFromCurrentRateService( + currencies: { [symbol: string]: string }, + dataGatheringItems: IDataGatheringItem[], + dates: Date[] + ): Promise<{ + dataProviderInfos: DataProviderInfo[]; + values: GetValueObject[]; + }> { + return await this.currentRateService.getValues({ + currencies, + dataGatheringItems, + dateQuery: { + in: dates + }, + userCurrency: this.currency }); } + private pushDataGatheringsSymbols( + transactionPointsBeforeEndDate: TransactionPoint[], + firstIndex: number, + dataGatheringItems: IDataGatheringItem[], + currencies: { [symbol: string]: string }, + symbols: { [symbol: string]: boolean } + ) { + for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { + dataGatheringItems.push({ + dataSource: item.dataSource, + symbol: item.symbol + }); + currencies[item.symbol] = item.currency; + symbols[item.symbol] = true; + } + } + + private getRelevantStartAndEndDates( + start: Date, + end: Date, + dates: Date[], + step: number + ) { + let day = start; + + while (isBefore(day, end)) { + dates.push(resetHours(day)); + day = addDays(day, step); + } + + if (!isSameDay(last(dates), end)) { + dates.push(resetHours(end)); + } + } + public async getCurrentPositions( start: Date, end = new Date(Date.now()) @@ -975,7 +1145,8 @@ export class PortfolioCalculator { maxInvestmentValues: {}, netPerformance: new Big(0), netPerformancePercentage: new Big(0), - netPerformanceValues: {} + netPerformanceValues: {}, + netPerformanceValuesPercentage: {} }; } @@ -1016,102 +1187,39 @@ export class PortfolioCalculator { let lastAveragePrice = new Big(0); let maxTotalInvestment = new Big(0); const netPerformanceValues: { [date: string]: Big } = {}; + const netPerformanceValuesPercentage: { [date: string]: Big } = {}; let totalInvestment = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalUnits = new Big(0); let valueAtStartDate: Big; // Add a synthetic order at the start and the end date - orders.push({ - symbol, - currency: null, - date: format(start, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - itemType: 'start', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtStartDate - }); - - orders.push({ + this.addSyntheticStartAndEndOrders( + orders, symbol, - currency: null, - date: format(end, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - itemType: 'end', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtEndDate - }); + start, + unitPriceAtStartDate, + end, + unitPriceAtEndDate + ); let day = start; let lastUnitPrice: Big; - if (isChartMode) { - const datesWithOrders = {}; - - for (const order of orders) { - datesWithOrders[order.date] = true; - } - - while (isBefore(day, end)) { - const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; - - if (!hasDate) { - orders.push({ - symbol, - currency: null, - date: format(day, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: - marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? - lastUnitPrice - }); - } else { - let orderIndex = orders.findIndex( - (o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE' - ); - if (orderIndex >= 0) { - let order = orders[orderIndex]; - orders.splice(orderIndex, 1); - orders.push({ - ...order, - unitPrice: - marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? - lastUnitPrice - }); - } - } - - lastUnitPrice = last(orders).unitPrice; - - day = addDays(day, step); - } - } + ({ day, lastUnitPrice } = this.handleChartMode( + isChartMode, + orders, + day, + end, + symbol, + marketSymbolMap, + lastUnitPrice, + step + )); // Sort orders so that the start and end placeholder order are at the right // position - orders = sortBy(orders, (order) => { - let sortIndex = new Date(order.date); - - if (order.itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); - } - - if (order.itemType === 'end') { - sortIndex = addMilliseconds(sortIndex, 1); - } - - return sortIndex.getTime(); - }); + orders = this.sortOrdersByTime(orders); const indexOfStartOrder = orders.findIndex((order) => { return order.itemType === 'start'; @@ -1121,121 +1229,290 @@ export class PortfolioCalculator { return order.itemType === 'end'; }); - for (let i = 0; i < orders.length; i += 1) { - const order = orders[i]; - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log(); - console.log(); - console.log(i + 1, order.type, order.itemType); - } - - if (order.itemType === 'start') { - // Take the unit price of the order as the market price if there are no - // orders of this symbol before the start date - order.unitPrice = - indexOfStartOrder === 0 - ? orders[i + 1]?.unitPrice - : unitPriceAtStartDate; - } - - // Calculate the average start price as soon as any units are held - if ( - averagePriceAtStartDate.eq(0) && - i >= indexOfStartOrder && - totalUnits.gt(0) - ) { - averagePriceAtStartDate = totalInvestment.div(totalUnits); - } - - const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPrice - ); + return this.calculatePerformanceOfSymbol( + orders, + indexOfStartOrder, + unitPriceAtStartDate, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + indexOfEndOrder, + averagePriceAtEndDate, + initialValue, + marketSymbolMap, + fees, + lastAveragePrice, + grossPerformanceFromSells, + totalInvestmentWithGrossPerformanceFromSell, + grossPerformance, + feesAtStartDate, + grossPerformanceAtStartDate, + isChartMode, + currentValues, + netPerformanceValues, + netPerformanceValuesPercentage, + investmentValues, + maxInvestmentValues, + unitPriceAtEndDate, + symbol + ); + } - if (!investmentAtStartDate && i >= indexOfStartOrder) { - investmentAtStartDate = totalInvestment ?? new Big(0); - valueAtStartDate = valueOfInvestmentBeforeTransaction; - } + private calculatePerformanceOfSymbol( + orders: PortfolioOrderItem[], + indexOfStartOrder: number, + unitPriceAtStartDate: Big, + averagePriceAtStartDate: Big, + totalUnits: Big, + totalInvestment: Big, + investmentAtStartDate: Big, + valueAtStartDate: Big, + maxTotalInvestment: Big, + indexOfEndOrder: number, + averagePriceAtEndDate: Big, + initialValue: Big, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + fees: Big, + lastAveragePrice: Big, + grossPerformanceFromSells: Big, + totalInvestmentWithGrossPerformanceFromSell: Big, + grossPerformance: Big, + feesAtStartDate: Big, + grossPerformanceAtStartDate: Big, + isChartMode: boolean, + currentValues: { [date: string]: Big }, + netPerformanceValues: { [date: string]: Big }, + netPerformanceValuesPercentage: { [date: string]: Big }, + investmentValues: { [date: string]: Big }, + maxInvestmentValues: { [date: string]: Big }, + unitPriceAtEndDate: Big, + symbol: string + ) { + ({ + lastAveragePrice, + grossPerformance, + feesAtStartDate, + grossPerformanceAtStartDate, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + initialValue, + fees, + netPerformanceValuesPercentage + } = this.handleOrders( + orders, + indexOfStartOrder, + unitPriceAtStartDate, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + initialValue, + fees, + indexOfEndOrder, + marketSymbolMap, + grossPerformanceFromSells, + totalInvestmentWithGrossPerformanceFromSell, + lastAveragePrice, + grossPerformance, + feesAtStartDate, + grossPerformanceAtStartDate, + isChartMode, + currentValues, + netPerformanceValues, + netPerformanceValuesPercentage, + investmentValues, + maxInvestmentValues + )); - const transactionInvestment = - order.type === 'BUY' || order.type === 'STAKE' - ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.toNumber()); - console.log('order.quantity', order.quantity.toNumber()); - console.log('transactionInvestment', transactionInvestment.toNumber()); - } + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); - totalInvestment = totalInvestment.plus(transactionInvestment); + const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( + maxTotalInvestment.minus(investmentAtStartDate) + ); - if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { - maxTotalInvestment = totalInvestment; - } + const grossPerformancePercentage = this.calculateGrossPerformancePercentage( + averagePriceAtStartDate, + averagePriceAtEndDate, + orders, + indexOfStartOrder, + maxInvestmentBetweenStartAndEndDate, + totalGrossPerformance, + unitPriceAtEndDate + ); - if (i === indexOfEndOrder && totalUnits.gt(0)) { - averagePriceAtEndDate = totalInvestment.div(totalUnits); - } + const feesPerUnit = totalUnits.gt(0) + ? fees.minus(feesAtStartDate).div(totalUnits) + : new Big(0); - if (i >= indexOfStartOrder && !initialValue) { - if ( - i === indexOfStartOrder && - !valueOfInvestmentBeforeTransaction.eq(0) - ) { - initialValue = valueOfInvestmentBeforeTransaction; - } else if (transactionInvestment.gt(0)) { - initialValue = transactionInvestment; - } else if (order.type === 'STAKE') { - // For Parachain Rewards or Stock SpinOffs, first transactionInvestment might be 0 if the symbol has been acquired for free - initialValue = order.quantity.mul( - marketSymbolMap[order.date]?.[order.symbol] ?? new Big(0) - ); - } - } + const netPerformancePercentage = this.calculateNetPerformancePercentage( + averagePriceAtStartDate, + averagePriceAtEndDate, + orders, + indexOfStartOrder, + maxInvestmentBetweenStartAndEndDate, + totalNetPerformance, + unitPriceAtEndDate, + feesPerUnit + ); - fees = fees.plus(order.fee); + this.handleLogging( + symbol, + orders, + indexOfStartOrder, + unitPriceAtEndDate, + averagePriceAtStartDate, + averagePriceAtEndDate, + totalInvestment, + maxTotalInvestment, + totalGrossPerformance, + grossPerformancePercentage, + feesPerUnit, + totalNetPerformance, + netPerformancePercentage + ); + return { + currentValues, + grossPerformancePercentage, + initialValue, + investmentValues, + maxInvestmentValues, + netPerformancePercentage, + netPerformanceValues, + grossPerformance: totalGrossPerformance, + hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + fees, + lastAveragePrice, + grossPerformanceFromSells, + totalInvestmentWithGrossPerformanceFromSell, + feesAtStartDate, + grossPerformanceAtStartDate, + netPerformanceValuesPercentage + }; + } - totalUnits = totalUnits.plus( - order.quantity.mul(this.getFactor(order.type)) + private handleOrders( + orders: PortfolioOrderItem[], + indexOfStartOrder: number, + unitPriceAtStartDate: Big, + averagePriceAtStartDate: Big, + totalUnits: Big, + totalInvestment: Big, + investmentAtStartDate: Big, + valueAtStartDate: Big, + maxTotalInvestment: Big, + averagePriceAtEndDate: Big, + initialValue: Big, + fees: Big, + indexOfEndOrder: number, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + grossPerformanceFromSells: Big, + totalInvestmentWithGrossPerformanceFromSell: Big, + lastAveragePrice: Big, + grossPerformance: Big, + feesAtStartDate: Big, + grossPerformanceAtStartDate: Big, + isChartMode: boolean, + currentValues: { [date: string]: Big }, + netPerformanceValues: { [date: string]: Big }, + netPerformanceValuesPercentage: { [date: string]: Big }, + investmentValues: { [date: string]: Big }, + maxInvestmentValues: { [date: string]: Big } + ) { + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + this.calculateNetPerformancePercentageForDateAndSymbol( + i, + orders, + order, + netPerformanceValuesPercentage, + marketSymbolMap ); - const valueOfInvestment = totalUnits.mul(order.unitPrice); - - const grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) - : new Big(0); + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log(i + 1, order.type, order.itemType); + } - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell + this.handleStartOrder( + order, + indexOfStartOrder, + orders, + i, + unitPriceAtStartDate ); - totalInvestmentWithGrossPerformanceFromSell = + // Calculate the average start price as soon as any units are held + let transactionInvestment; + let valueOfInvestment; + ({ + transactionInvestment, + valueOfInvestment, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + initialValue, + fees + } = this.calculateInvestmentSpecificMetrics( + averagePriceAtStartDate, + i, + indexOfStartOrder, + totalUnits, + totalInvestment, + order, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + indexOfEndOrder, + initialValue, + marketSymbolMap, + fees + )); + ({ + grossPerformanceFromSells, totalInvestmentWithGrossPerformanceFromSell - .plus(transactionInvestment) - .plus(grossPerformanceFromSell); + } = this.calculateSellOrders( + order, + lastAveragePrice, + grossPerformanceFromSells, + totalInvestmentWithGrossPerformanceFromSell, + transactionInvestment + )); lastAveragePrice = totalUnits.eq(0) ? new Big(0) : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - 'totalInvestmentWithGrossPerformanceFromSell', - totalInvestmentWithGrossPerformanceFromSell.toNumber() - ); - console.log( - 'grossPerformanceFromSells', - grossPerformanceFromSells.toNumber() - ); - } - const newGrossPerformance = valueOfInvestment .minus(totalInvestment) .plus(grossPerformanceFromSells); @@ -1247,82 +1524,418 @@ export class PortfolioCalculator { grossPerformanceAtStartDate = grossPerformance; } - if (isChartMode && i > indexOfStartOrder) { - currentValues[order.date] = valueOfInvestment; - netPerformanceValues[order.date] = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - - investmentValues[order.date] = totalInvestment; - maxInvestmentValues[order.date] = maxTotalInvestment; - } + this.calculatePerformancesForDate( + isChartMode, + i, + indexOfStartOrder, + currentValues, + order, + valueOfInvestment, + netPerformanceValues, + grossPerformance, + grossPerformanceAtStartDate, + fees, + feesAtStartDate, + investmentValues, + totalInvestment, + maxInvestmentValues, + maxTotalInvestment + ); - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.toNumber()); - console.log( - 'totalGrossPerformance', - grossPerformance.minus(grossPerformanceAtStartDate).toNumber() - ); - } + this.handleLoggingOfInvestmentMetrics( + totalInvestment, + order, + transactionInvestment, + totalInvestmentWithGrossPerformanceFromSell, + grossPerformanceFromSells, + grossPerformance, + grossPerformanceAtStartDate + ); if (i === indexOfEndOrder) { break; } } + return { + lastAveragePrice, + grossPerformance, + feesAtStartDate, + grossPerformanceAtStartDate, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + initialValue, + fees, + netPerformanceValuesPercentage + }; + } - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalNetPerformance = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); + private calculateNetPerformancePercentageForDateAndSymbol( + i: number, + orders: PortfolioOrderItem[], + order: PortfolioOrderItem, + netPerformanceValuesPercentage: { [date: string]: Big }, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } } + ) { + if (i > 0 && order) { + const previousOrder = orders[i - 1]; + + if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) { + netPerformanceValuesPercentage[order.date] = previousOrder.unitPrice + .div(order.unitPrice) + .minus(1); + } else if ( + order.type === 'STAKE' && + marketSymbolMap[order.date][order.symbol] + ) { + netPerformanceValuesPercentage[order.date] = + previousOrder.type === 'STAKE' + ? marketSymbolMap[previousOrder.date][previousOrder.symbol] + : previousOrder.unitPrice + .div(marketSymbolMap[order.date][order.symbol]) + .minus(1); + } else if (previousOrder.unitPrice.toNumber()) { + netPerformanceValuesPercentage[order.date] = new Big(-1); + } else if (previousOrder.type === 'STAKE' && order.unitPrice.toNumber()) { + netPerformanceValuesPercentage[order.date] = marketSymbolMap[ + previousOrder.date + ][previousOrder.symbol] + .div(order.unitPrice) + .minus(1); + } else { + netPerformanceValuesPercentage[order.date] = new Big(0); + } + } + } - const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( - maxTotalInvestment.minus(investmentAtStartDate) - ); + private handleLoggingOfInvestmentMetrics( + totalInvestment: Big, + order: PortfolioOrderItem, + transactionInvestment: any, + totalInvestmentWithGrossPerformanceFromSell: Big, + grossPerformanceFromSells: Big, + grossPerformance: Big, + grossPerformanceAtStartDate: Big + ) { + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('totalInvestment', totalInvestment.toNumber()); + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + console.log( + 'totalInvestmentWithGrossPerformanceFromSell', + totalInvestmentWithGrossPerformanceFromSell.toNumber() + ); + console.log( + 'grossPerformanceFromSells', + grossPerformanceFromSells.toNumber() + ); + console.log('totalInvestment', totalInvestment.toNumber()); + console.log( + 'totalGrossPerformance', + grossPerformance.minus(grossPerformanceAtStartDate).toNumber() + ); + } + } - const grossPerformancePercentage = - PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || + private calculateNetPerformancePercentage( + averagePriceAtStartDate: Big, + averagePriceAtEndDate: Big, + orders: PortfolioOrderItem[], + indexOfStartOrder: number, + maxInvestmentBetweenStartAndEndDate: Big, + totalNetPerformance: Big, + unitPriceAtEndDate: Big, + feesPerUnit: Big + ) { + return PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || averagePriceAtStartDate.eq(0) || averagePriceAtEndDate.eq(0) || orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); - - const feesPerUnit = totalUnits.gt(0) - ? fees.minus(feesAtStartDate).div(totalUnits) - : new Big(0); + ? maxInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) + : new Big(0) + : // This formula has the issue that buying more units with a price + + // lower than the average buying price results in a positive + // performance even if the market price stays constant + unitPriceAtEndDate + .minus(feesPerUnit) + .div(averagePriceAtEndDate) + .div(orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)) + .minus(1); + } - const netPerformancePercentage = - PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || + private calculateGrossPerformancePercentage( + averagePriceAtStartDate: Big, + averagePriceAtEndDate: Big, + orders: PortfolioOrderItem[], + indexOfStartOrder: number, + maxInvestmentBetweenStartAndEndDate: Big, + totalGrossPerformance: Big, + unitPriceAtEndDate: Big + ) { + return PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || averagePriceAtStartDate.eq(0) || averagePriceAtEndDate.eq(0) || orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .minus(feesPerUnit) - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); + ? maxInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) + : new Big(0) + : // This formula has the issue that buying more units with a price + + // lower than the average buying price results in a positive + // performance even if the market price stays constant + unitPriceAtEndDate + .div(averagePriceAtEndDate) + .div(orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)) + .minus(1); + } + + private calculateInvestmentSpecificMetrics( + averagePriceAtStartDate: Big, + i: number, + indexOfStartOrder: number, + totalUnits: Big, + totalInvestment: Big, + order: PortfolioOrderItem, + investmentAtStartDate: Big, + valueAtStartDate: Big, + maxTotalInvestment: Big, + averagePriceAtEndDate: Big, + indexOfEndOrder: number, + initialValue: Big, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + fees: Big + ) { + averagePriceAtStartDate = this.calculateAveragePrice( + averagePriceAtStartDate, + i, + indexOfStartOrder, + totalUnits, + totalInvestment + ); + + const valueOfInvestmentBeforeTransaction = totalUnits.mul(order.unitPrice); + if (!investmentAtStartDate && i >= indexOfStartOrder) { + investmentAtStartDate = totalInvestment ?? new Big(0); + valueAtStartDate = valueOfInvestmentBeforeTransaction; + } + + const transactionInvestment = this.getTransactionInvestment( + order, + totalUnits, + totalInvestment + ); + + totalInvestment = totalInvestment.plus(transactionInvestment); + + if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { + maxTotalInvestment = totalInvestment; + } + + averagePriceAtEndDate = this.calculateAveragePriceAtEnd( + i, + indexOfEndOrder, + totalUnits, + averagePriceAtEndDate, + totalInvestment + ); + + initialValue = this.calculateInitialValue( + i, + indexOfStartOrder, + initialValue, + valueOfInvestmentBeforeTransaction, + transactionInvestment, + order, + marketSymbolMap + ); + + fees = fees.plus(order.fee); + + totalUnits = totalUnits.plus( + order.quantity.mul(this.getFactor(order.type)) + ); + + const valueOfInvestment = totalUnits.mul(order.unitPrice); + return { + transactionInvestment, + valueOfInvestment, + averagePriceAtStartDate, + totalUnits, + totalInvestment, + investmentAtStartDate, + valueAtStartDate, + maxTotalInvestment, + averagePriceAtEndDate, + initialValue, + fees + }; + } + + private calculatePerformancesForDate( + isChartMode: boolean, + i: number, + indexOfStartOrder: number, + currentValues: { [date: string]: Big }, + order: PortfolioOrderItem, + valueOfInvestment: Big, + netPerformanceValues: { [date: string]: Big }, + grossPerformance: Big, + grossPerformanceAtStartDate: Big, + fees: Big, + feesAtStartDate: Big, + investmentValues: { [date: string]: Big }, + totalInvestment: Big, + maxInvestmentValues: { [date: string]: Big }, + maxTotalInvestment: Big + ) { + if (isChartMode && i > indexOfStartOrder) { + currentValues[order.date] = valueOfInvestment; + netPerformanceValues[order.date] = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + investmentValues[order.date] = totalInvestment; + maxInvestmentValues[order.date] = maxTotalInvestment; + } + } + + private calculateSellOrders( + order: PortfolioOrderItem, + lastAveragePrice: Big, + grossPerformanceFromSells: Big, + totalInvestmentWithGrossPerformanceFromSell: Big, + transactionInvestment: Big + ) { + const grossPerformanceFromSell = + order.type === TypeOfOrder.SELL + ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + totalInvestmentWithGrossPerformanceFromSell = + totalInvestmentWithGrossPerformanceFromSell + .plus(transactionInvestment) + .plus(grossPerformanceFromSell); + return { + grossPerformanceFromSells, + totalInvestmentWithGrossPerformanceFromSell + }; + } + + private calculateInitialValue( + i: number, + indexOfStartOrder: number, + initialValue: Big, + valueOfInvestmentBeforeTransaction: Big, + transactionInvestment: Big, + order: PortfolioOrderItem, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } } + ) { + if (i >= indexOfStartOrder && !initialValue) { + if ( + i === indexOfStartOrder && + !valueOfInvestmentBeforeTransaction.eq(0) + ) { + initialValue = valueOfInvestmentBeforeTransaction; + } else if (transactionInvestment.gt(0)) { + initialValue = transactionInvestment; + } else if (order.type === 'STAKE') { + // For Parachain Rewards or Stock SpinOffs, first transactionInvestment might be 0 if the symbol has been acquired for free + initialValue = order.quantity.mul( + marketSymbolMap[order.date]?.[order.symbol] ?? new Big(0) + ); + } + } + return initialValue; + } + + private calculateAveragePriceAtEnd( + i: number, + indexOfEndOrder: number, + totalUnits: Big, + averagePriceAtEndDate: Big, + totalInvestment: Big + ) { + if (i === indexOfEndOrder && totalUnits.gt(0)) { + averagePriceAtEndDate = totalInvestment.div(totalUnits); + } + return averagePriceAtEndDate; + } + + private getTransactionInvestment( + order: PortfolioOrderItem, + totalUnits: Big, + totalInvestment: Big + ) { + return order.type === 'BUY' || order.type === 'STAKE' + ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) + : totalUnits.gt(0) + ? totalInvestment + .div(totalUnits) + .mul(order.quantity) + .mul(this.getFactor(order.type)) + : new Big(0); + } + + private calculateAveragePrice( + averagePriceAtStartDate: Big, + i: number, + indexOfStartOrder: number, + totalUnits: Big, + totalInvestment: Big + ) { + if ( + averagePriceAtStartDate.eq(0) && + i >= indexOfStartOrder && + totalUnits.gt(0) + ) { + averagePriceAtStartDate = totalInvestment.div(totalUnits); + } + return averagePriceAtStartDate; + } + + private handleStartOrder( + order: PortfolioOrderItem, + indexOfStartOrder: number, + orders: PortfolioOrderItem[], + i: number, + unitPriceAtStartDate: Big + ) { + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + indexOfStartOrder === 0 + ? orders[i + 1]?.unitPrice + : unitPriceAtStartDate; + } + } + + private handleLogging( + symbol: string, + orders: PortfolioOrderItem[], + indexOfStartOrder: number, + unitPriceAtEndDate: Big, + averagePriceAtStartDate: Big, + averagePriceAtEndDate: Big, + totalInvestment: Big, + maxTotalInvestment: Big, + totalGrossPerformance: Big, + grossPerformancePercentage: Big, + feesPerUnit: Big, + totalNetPerformance: Big, + netPerformancePercentage: Big + ) { if (PortfolioCalculator.ENABLE_LOGGING) { console.log( ` @@ -1344,19 +1957,132 @@ export class PortfolioCalculator { )} / ${netPerformancePercentage.mul(100).toFixed(2)}%` ); } + } - return { - currentValues, - grossPerformancePercentage, - initialValue, - investmentValues, - maxInvestmentValues, - netPerformancePercentage, - netPerformanceValues, - grossPerformance: totalGrossPerformance, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance - }; + private sortOrdersByTime(orders: PortfolioOrderItem[]) { + orders = sortBy(orders, (order) => { + let sortIndex = new Date(order.date); + + if (order.itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + if (order.itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } + + return sortIndex.getTime(); + }); + return orders; + } + + private handleChartMode( + isChartMode: boolean, + orders: PortfolioOrderItem[], + day: Date, + end: Date, + symbol: string, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + lastUnitPrice: Big, + step: number + ) { + if (isChartMode) { + const datesWithOrders = {}; + + for (const order of orders) { + datesWithOrders[order.date] = true; + } + + while (isBefore(day, end)) { + this.handleDay( + datesWithOrders, + day, + orders, + symbol, + marketSymbolMap, + lastUnitPrice + ); + + lastUnitPrice = last(orders).unitPrice; + + day = addDays(day, step); + } + } + return { day, lastUnitPrice }; + } + + private handleDay( + datesWithOrders: {}, + day: Date, + orders: PortfolioOrderItem[], + symbol: string, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + lastUnitPrice: Big + ) { + const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; + + if (!hasDate) { + orders.push({ + symbol, + currency: null, + date: format(day, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: + marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? lastUnitPrice + }); + } else { + let orderIndex = orders.findIndex( + (o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE' + ); + if (orderIndex >= 0) { + let order = orders[orderIndex]; + orders.splice(orderIndex, 1); + orders.push({ + ...order, + unitPrice: + marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? lastUnitPrice + }); + } + } + } + + private addSyntheticStartAndEndOrders( + orders: PortfolioOrderItem[], + symbol: string, + start: Date, + unitPriceAtStartDate: Big, + end: Date, + unitPriceAtEndDate: Big + ) { + orders.push({ + symbol, + currency: null, + date: format(start, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + itemType: 'start', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtStartDate + }); + + orders.push({ + symbol, + currency: null, + date: format(end, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + itemType: 'end', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtEndDate + }); } private isNextItemActive( diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index d5da0a333..d31ce8aa8 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -359,7 +359,8 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccounts = false + @Query('withExcludedAccounts') withExcludedAccounts = false, + @Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, @@ -372,7 +373,8 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - userId: this.request.user.id + userId: this.request.user.id, + calculateTimeWeightedPerformance }); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 9bc585397..730a22c57 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1153,13 +1153,15 @@ export class PortfolioService { filters, impersonationId, userId, - withExcludedAccounts = false + withExcludedAccounts = false, + calculateTimeWeightedPerformance = false }: { 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 }); @@ -1254,7 +1256,8 @@ export class PortfolioService { portfolioOrders, transactionPoints, userCurrency, - userId + userId, + calculateTimeWeightedPerformance }); const itemOfToday = items.find(({ date }) => { @@ -1463,7 +1466,8 @@ export class PortfolioService { portfolioOrders, transactionPoints, userCurrency, - userId + userId, + calculateTimeWeightedPerformance }: { dateRange?: DateRange; impersonationId: string; @@ -1471,6 +1475,7 @@ export class PortfolioService { transactionPoints: TransactionPoint[]; userCurrency: string; userId: string; + calculateTimeWeightedPerformance: boolean; }): Promise { if (transactionPoints.length === 0) { return { @@ -1503,7 +1508,8 @@ export class PortfolioService { const items = await portfolioCalculator.getChartData( startDate, endDate, - step + step, + calculateTimeWeightedPerformance ); return { From 5d28c9e6e815951929b1971c143349e0fd45cd65 Mon Sep 17 00:00:00 2001 From: Daniel Devaud Date: Sun, 17 Dec 2023 17:44:16 +0100 Subject: [PATCH 2/4] Added Time Weighted Performance calculation Not correct yet --- .../src/app/portfolio/portfolio-calculator.ts | 36 +++++++---- .../src/app/user/update-user-setting.dto.ts | 4 ++ .../benchmark-comparator.component.scss | 1 + .../benchmark-comparator.component.ts | 15 ++++- .../analysis/analysis-page.component.ts | 60 +++++++++++++++++-- .../portfolio/analysis/analysis-page.html | 18 +++++- apps/client/src/app/services/data.service.ts | 10 +++- .../historical-data-item.interface.ts | 1 + .../lib/interfaces/user-settings.interface.ts | 1 + 9 files changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 263992bfa..e71f59478 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1585,26 +1585,38 @@ export class PortfolioCalculator { const previousOrder = orders[i - 1]; if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = previousOrder.unitPrice - .div(order.unitPrice) + netPerformanceValuesPercentage[order.date] = order.unitPrice + .div(previousOrder.unitPrice) .minus(1); } else if ( order.type === 'STAKE' && - marketSymbolMap[order.date][order.symbol] + marketSymbolMap[order.date][order.symbol] && + ((marketSymbolMap[previousOrder.date][ + previousOrder.symbol + ]?.toNumber() && + previousOrder.type === 'STAKE') || + (previousOrder.type !== 'STAKE' && + previousOrder.unitPrice.toNumber())) ) { - netPerformanceValuesPercentage[order.date] = + let previousUnitPrice = previousOrder.type === 'STAKE' ? marketSymbolMap[previousOrder.date][previousOrder.symbol] - : previousOrder.unitPrice - .div(marketSymbolMap[order.date][order.symbol]) - .minus(1); + : previousOrder.unitPrice; + netPerformanceValuesPercentage[order.date] = marketSymbolMap[ + order.date + ][order.symbol] + ? marketSymbolMap[order.date][order.symbol] + .div(previousUnitPrice) + .minus(1) + : new Big(0); } else if (previousOrder.unitPrice.toNumber()) { netPerformanceValuesPercentage[order.date] = new Big(-1); - } else if (previousOrder.type === 'STAKE' && order.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = marketSymbolMap[ - previousOrder.date - ][previousOrder.symbol] - .div(order.unitPrice) + } else if ( + previousOrder.type === 'STAKE' && + marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber() + ) { + netPerformanceValuesPercentage[order.date] = order.unitPrice + .div(marketSymbolMap[previousOrder.date][previousOrder.symbol]) .minus(1); } else { netPerformanceValuesPercentage[order.date] = new Big(0); diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index b1967faba..1c8e42cd0 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -68,4 +68,8 @@ export class UpdateUserSettingDto { @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; + + @IsIn(['N', 'B', 'O']) + @IsOptional() + timeWeightedPerformance?: string; } diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss index e02c91e3d..af1248fec 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss @@ -7,5 +7,6 @@ ngx-skeleton-loader { height: 100%; } + margin-bottom: 0.5rem; } } diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index 9a6bd1d30..dbb0aa792 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -53,6 +53,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { @Input() isLoading: boolean; @Input() locale: string; @Input() performanceDataItems: LineChartItem[]; + @Input() timeWeightedPerformanceDataItems: LineChartItem[]; @Input() user: User; @Output() benchmarkChanged = new EventEmitter(); @@ -83,7 +84,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { permissions.accessAdminControl ); - if (this.performanceDataItems) { + if ( + this.performanceDataItems || + this.timeWeightedPerformanceDataItems?.length > 0 + ) { this.initialize(); } } @@ -108,6 +112,15 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { }), label: $localize`Portfolio` }, + { + backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderWidth: 2, + data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => { + return { x: parseDate(date).getTime(), y: value }; + }), + label: $localize`Portfolio (time-weighted)` + }, { backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 51dcee24c..c27ab5b65 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -19,6 +19,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { AssetClass, DataSource, SymbolProfile } from '@prisma/client'; +import Big from 'big.js'; import { differenceInDays } from 'date-fns'; import { isNumber, sortBy } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -37,6 +38,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public benchmarks: Partial[]; public bottom3: Position[]; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; + public timeWeightedPerformanceOptions = [ + { label: $localize`No`, value: 'N' }, + { label: $localize`Both`, value: 'B' }, + { label: $localize`Only`, value: 'O' } + ]; + public selectedTimeWeightedPerformanceOption: string; public daysInMarket: number; public deviceType: string; public dividendsByGroup: InvestmentItem[]; @@ -56,6 +63,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ]; public performanceDataItems: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[]; + public performanceDataItemsTimeWeightedInPercentage: HistoricalDataItem[] = + []; public placeholder = ''; public portfolioEvolutionDataLabel = $localize`Deposit`; public streaks: PortfolioInvestments['streaks']; @@ -212,6 +221,24 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); } + public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) { + this.dataService + .putUserSetting({ timeWeightedPerformance }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onChangeGroupBy(aMode: GroupBy) { this.mode = aMode; this.fetchDividendsAndInvestments(); @@ -252,16 +279,16 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ? translate('YEAR') : translate('YEARS') : this.streaks?.currentStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.unitLongestStreak = this.mode === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') : this.streaks?.longestStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.changeDetectorRef.markForCheck(); }); @@ -314,7 +341,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchPortfolioPerformance({ filters: this.activeFilters, - range: this.user?.settings?.dateRange + range: this.user?.settings?.dateRange, + timeWeightedPerformance: + this.user?.settings?.timeWeightedPerformance === 'N' ? false : true }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart, firstOrderDate }) => { @@ -324,6 +353,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.investments = []; this.performanceDataItems = []; this.performanceDataItemsInPercentage = []; + this.performanceDataItemsTimeWeightedInPercentage = []; for (const [ index, @@ -332,7 +362,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { netPerformanceInPercentage, totalInvestment, value, - valueInPercentage + valueInPercentage, + timeWeightedPerformance } ] of chart.entries()) { if (index > 0 || this.user?.settings?.dateRange === 'max') { @@ -347,6 +378,23 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { date, value: netPerformanceInPercentage }); + if ((this.user?.settings?.timeWeightedPerformance ?? 'N') !== 'N') { + let lastPerformance = 0; + if (index > 0) { + lastPerformance = new Big( + chart[index - 1].timeWeightedPerformance + ) + .plus(1) + .mul(new Big(chart[index].timeWeightedPerformance).plus(1)) + .minus(1) + .toNumber(); + } + chart[index].timeWeightedPerformance = lastPerformance; + this.performanceDataItemsTimeWeightedInPercentage.push({ + date, + value: lastPerformance + }); + } } this.isLoadingInvestmentChart = false; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index f875907bb..7e15345bb 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -17,7 +17,7 @@
+
+
+
+ Include time-weighted performance + +
+
+
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index e61fa2406..647e66879 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -398,11 +398,13 @@ export class DataService { public fetchPortfolioPerformance({ filters, range, - withExcludedAccounts = false + withExcludedAccounts = false, + timeWeightedPerformance = false }: { filters?: Filter[]; range: DateRange; withExcludedAccounts?: boolean; + timeWeightedPerformance?: boolean; }): Observable { let params = this.buildFiltersAsQueryParams({ filters }); params = params.append('range', range); @@ -410,6 +412,12 @@ export class DataService { if (withExcludedAccounts) { params = params.append('withExcludedAccounts', withExcludedAccounts); } + if (timeWeightedPerformance) { + params = params.append( + 'timeWeightedPerformance', + timeWeightedPerformance + ); + } return this.http .get(`/api/v2/portfolio/performance`, { 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 b348e33aa..8b23b825d 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -12,4 +12,5 @@ export interface HistoricalDataItem { totalInvestment?: number; value?: number; valueInPercentage?: number; + timeWeightedPerformance?: number; } diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index d3864ab64..716f632a7 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -15,4 +15,5 @@ export interface UserSettings { retirementDate?: string; savingsRate?: number; viewMode?: ViewMode; + timeWeightedPerformance?: string; } From b6c24a53af09fd673959ccb0feda5669ab1e89d8 Mon Sep 17 00:00:00 2001 From: Daniel Devaud Date: Wed, 20 Dec 2023 14:58:38 +0100 Subject: [PATCH 3/4] Added Time Weighted Performance to front end --- .../src/app/portfolio/portfolio-calculator.ts | 49 +++++++++++++------ .../src/app/user/update-user-setting.dto.ts | 4 -- .../benchmark-comparator.component.ts | 1 + .../analysis/analysis-page.component.ts | 27 ++++------ .../portfolio/analysis/analysis-page.html | 6 +-- .../lib/interfaces/user-settings.interface.ts | 1 - 6 files changed, 49 insertions(+), 39 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index e71f59478..b165ed5c6 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -240,15 +240,15 @@ export class PortfolioCalculator { this.getRelevantStartAndEndDates(start, end, dates, step); const dataGartheringDates = [ ...dates, - ...this.orders.map((o) => { - let dateParsed = Date.parse(o.date); - if (isBefore(dateParsed, end) && isAfter(dateParsed, start)) { - let date = new Date(dateParsed); - if (dates.indexOf(date) === -1) { - return date; - } - } - }) + ...this.orders + .filter((o) => { + let dateParsed = Date.parse(o.date); + return isBefore(dateParsed, end) && isAfter(dateParsed, start); + }) + .map((o) => { + let dateParsed = Date.parse(o.date); + return new Date(dateParsed); + }) ]; const { dataProviderInfos, values: marketSymbols } = @@ -285,12 +285,26 @@ export class PortfolioCalculator { valuesBySymbol ); + let valuesBySymbolShortend: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + investmentValues: { [date: string]: Big }; + maxInvestmentValues: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesPercentage: { [date: string]: Big }; + }; + } = {}; + Object.keys(valuesBySymbol).forEach((k) => { + if (valuesBySymbol[k].currentValues) { + Object.assign(valuesBySymbolShortend, { [k]: valuesBySymbol[k] }); + } + }); return dates.map((date: Date, index: number, dates: Date[]) => { let previousDate: Date = index > 0 ? dates[index - 1] : null; return this.calculatePerformance( date, previousDate, - valuesBySymbol, + valuesBySymbolShortend, calculateTimeWeightedPerformance ); }); @@ -347,17 +361,23 @@ export class PortfolioCalculator { ); if ( - calculateTimeWeightedPerformance && previousTotalInvestmentValue.toNumber() && - symbolValues.netPerformanceValuesPercentage + symbolValues.netPerformanceValuesPercentage && + ( + symbolValues.currentValues?.[previousDateString] ?? new Big(0) + ).toNumber() ) { const previousValue = symbolValues.currentValues?.[previousDateString] ?? new Big(0); const netPerformance = symbolValues.netPerformanceValuesPercentage?.[dateString] ?? new Big(0); + const timeWeightedPerformanceContribution = previousValue + .div(previousTotalInvestmentValue) + .mul(netPerformance) + .mul(100); timeWeightedPerformance = timeWeightedPerformance.plus( - previousValue.div(previousTotalInvestmentValue).mul(netPerformance) + timeWeightedPerformanceContribution ); } } @@ -1590,7 +1610,7 @@ export class PortfolioCalculator { .minus(1); } else if ( order.type === 'STAKE' && - marketSymbolMap[order.date][order.symbol] && + marketSymbolMap[order.date] && ((marketSymbolMap[previousOrder.date][ previousOrder.symbol ]?.toNumber() && @@ -1613,6 +1633,7 @@ export class PortfolioCalculator { netPerformanceValuesPercentage[order.date] = new Big(-1); } else if ( previousOrder.type === 'STAKE' && + marketSymbolMap[previousOrder.date] && marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber() ) { netPerformanceValuesPercentage[order.date] = order.unitPrice diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 1c8e42cd0..b1967faba 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -68,8 +68,4 @@ export class UpdateUserSettingDto { @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; - - @IsIn(['N', 'B', 'O']) - @IsOptional() - timeWeightedPerformance?: string; } diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index dbb0aa792..05a14d43a 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -116,6 +116,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderWidth: 2, + borderDash: [5, 5], data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => { return { x: parseDate(date).getTime(), y: value }; }), diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index c27ab5b65..f28b8f272 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -68,6 +68,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public placeholder = ''; public portfolioEvolutionDataLabel = $localize`Deposit`; public streaks: PortfolioInvestments['streaks']; + public timeWeightedPerformance: string = 'N'; public top3: Position[]; public unitCurrentStreak: string; public unitLongestStreak: string; @@ -222,21 +223,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { } public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) { - this.dataService - .putUserSetting({ timeWeightedPerformance }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); + this.timeWeightedPerformance = timeWeightedPerformance; - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); + this.update(); } public onChangeGroupBy(aMode: GroupBy) { @@ -343,7 +332,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { filters: this.activeFilters, range: this.user?.settings?.dateRange, timeWeightedPerformance: - this.user?.settings?.timeWeightedPerformance === 'N' ? false : true + this.timeWeightedPerformance === 'N' ? false : true }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart, firstOrderDate }) => { @@ -378,15 +367,19 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { date, value: netPerformanceInPercentage }); - if ((this.user?.settings?.timeWeightedPerformance ?? 'N') !== 'N') { + if ((this.timeWeightedPerformance ?? 'N') !== 'N') { let lastPerformance = 0; if (index > 0) { lastPerformance = new Big( chart[index - 1].timeWeightedPerformance ) + .div(100) .plus(1) - .mul(new Big(chart[index].timeWeightedPerformance).plus(1)) + .mul( + new Big(chart[index].timeWeightedPerformance).div(100).plus(1) + ) .minus(1) + .mul(100) .toNumber(); } chart[index].timeWeightedPerformance = lastPerformance; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 7e15345bb..ace412ce1 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -25,8 +25,8 @@ [daysInMarket]="daysInMarket" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [locale]="user?.settings?.locale" - [performanceDataItems]="performanceDataItemsInPercentage" - [timeWeightedPerformanceDataItems]="performanceDataItemsTimeWeightedInPercentage" + [performanceDataItems]="timeWeightedPerformance === 'O' ? [] :performanceDataItemsInPercentage" + [timeWeightedPerformanceDataItems]="timeWeightedPerformance === 'N' ? [] :performanceDataItemsTimeWeightedInPercentage" [user]="user" (benchmarkChanged)="onChangeBenchmark($event)" > @@ -37,7 +37,7 @@ > Include time-weighted performance Date: Wed, 20 Dec 2023 15:13:20 +0100 Subject: [PATCH 4/4] Fix test --- .../portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts index feed5923b..62f5b93ba 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -77,6 +77,7 @@ describe('PortfolioCalculator', () => { date: '2022-03-07', netPerformanceInPercentage: 0, netPerformance: 0, + timeWeightedPerformance: 0, totalInvestment: 151.6, value: 151.6 }); @@ -85,6 +86,7 @@ describe('PortfolioCalculator', () => { date: '2022-04-11', netPerformanceInPercentage: 13.100263852242744, netPerformance: 19.86, + timeWeightedPerformance: 0, totalInvestment: 0, value: 0 });