diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 0be9af1c00..507dd731e2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -264,7 +264,7 @@ describe('PortfolioCalculator', () => { '2017': { date: '2017-12-31', netPerformance: 26957.033439, - netPerformanceInPercentage: 44.10965416175681, + netPerformanceInPercentage: 44.10965416175668, netPerformanceInPercentageWithCurrencyEffect: 43.148434302822835, netPerformanceWithCurrencyEffect: 27081.23736, totalInvestmentValueWithCurrencyEffect: 318.54266729999995, @@ -273,8 +273,8 @@ describe('PortfolioCalculator', () => { max: { date: '2018-01-01', netPerformance: 26458.9121202, - netPerformanceInPercentage: 42.43911719562077, - netPerformanceInPercentageWithCurrencyEffect: 41.65910103572173, + netPerformanceInPercentage: 42.439117195620575, + netPerformanceInPercentageWithCurrencyEffect: 41.65910103572163, netPerformanceWithCurrencyEffect: 26516.2087014, totalInvestmentValueWithCurrencyEffect: 318.54266729999995, valueWithCurrencyEffect: 13298.425356 @@ -346,7 +346,7 @@ describe('PortfolioCalculator', () => { // Other weights give another rounding of the floating point numbers expect( performanceByDateRange['2017'].netPerformanceInPercentage - ).toBeCloseTo(44.10965416175681, 10); + ).toBeCloseTo(44.10965416175668, 10); expect( performanceByDateRange['2017'] @@ -354,13 +354,13 @@ describe('PortfolioCalculator', () => { ).toBeCloseTo(43.148434302822835, 10); expect(performanceByDateRange.max.netPerformanceInPercentage).toBeCloseTo( - 42.43911719562077, + 42.439117195620575, 10 ); expect( performanceByDateRange.max.netPerformanceInPercentageWithCurrencyEffect - ).toBeCloseTo(41.65910103572173, 10); + ).toBeCloseTo(41.65910103572163, 10); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 0115b4a57c..7483250e1f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -1,8 +1,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; +import { AverageInvestmentItem } from '@ghostfolio/api/app/portfolio/interfaces/average-investment-item.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; +import { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; import { getAnnualizedPerformancePercent, @@ -29,6 +31,7 @@ import { isBefore, isThisYear } from 'date-fns'; +import { sortedIndex } from 'lodash-es'; export class RoaiPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance( @@ -209,77 +212,60 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }: { historicalDataItems: HistoricalDataItem[]; }): { [date: string]: PerformancePercentages } { - let grossPerformanceAtStartDate: number; - let grossPerformanceWithCurrencyEffectAtStartDate: number; - let previousDate: Date; - let sumOfWeightedInvestments = 0; - let sumOfWeightedInvestmentsWithCurrencyEffect = 0; - let totalInvestmentDays = 0; - let totalInvestmentDaysWithCurrencyEffect = 0; - const performancePercentagesByDate: { [date: string]: PerformancePercentages; } = {}; - for (const [index, historicalDataItem] of historicalDataItems.entries()) { - const date = parseDate(historicalDataItem.date); - - // Take the values at the start date from the first day of the date - // range - if (index === 0) { - grossPerformanceAtStartDate = - historicalDataItem.value - historicalDataItem.totalInvestment; - - grossPerformanceWithCurrencyEffectAtStartDate = - historicalDataItem.valueWithCurrencyEffect - - historicalDataItem.totalInvestmentValueWithCurrencyEffect; - } else { - // The chart dates are not evenly spaced, and the investment changes on - // a chart date only. Thus the investment of the previous chart date - // applies to each day until this chart date. - const previousHistoricalDataItem = historicalDataItems[index - 1]; - - const daysSincePreviousChartDate = differenceInDays(date, previousDate); + if (historicalDataItems.length === 0) { + return performancePercentagesByDate; + } - if (previousHistoricalDataItem.totalInvestment > 0) { - sumOfWeightedInvestments += - (previousHistoricalDataItem.totalInvestment + - grossPerformanceAtStartDate) * - daysSincePreviousChartDate; + const parsedDates = historicalDataItems.map(({ date }) => { + return parseDate(date); + }); - totalInvestmentDays += daysSincePreviousChartDate; - } + const daysUntilNextItem = parsedDates.slice(1).map((nextDate, index) => { + return differenceInDays(nextDate, parsedDates[index]); + }); - if ( - previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect > 0 - ) { - sumOfWeightedInvestmentsWithCurrencyEffect += - (previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect + - grossPerformanceWithCurrencyEffectAtStartDate) * - daysSincePreviousChartDate; + // Take the values at the start date from the first day of the date range + const [firstHistoricalDataItem] = historicalDataItems; + + const weightedInvestments = this.getWeightedInvestments({ + grossPerformanceAtStartDate: new Big(firstHistoricalDataItem.value).minus( + firstHistoricalDataItem.totalInvestment + ), + items: historicalDataItems.map(({ totalInvestment }, index) => { + return { + daysUntilNextItem: daysUntilNextItem[index], + investment: new Big(totalInvestment) + }; + }) + }); - totalInvestmentDaysWithCurrencyEffect += daysSincePreviousChartDate; + const weightedInvestmentsWithCurrencyEffect = this.getWeightedInvestments({ + grossPerformanceAtStartDate: new Big( + firstHistoricalDataItem.valueWithCurrencyEffect + ).minus(firstHistoricalDataItem.totalInvestmentValueWithCurrencyEffect), + items: historicalDataItems.map( + ({ totalInvestmentValueWithCurrencyEffect }, index) => { + return { + daysUntilNextItem: daysUntilNextItem[index], + investment: new Big(totalInvestmentValueWithCurrencyEffect) + }; } - } + ) + }); - // Add the gross performance at the start date of the range to the - // investment of each day. Thus the range starts with the value of its - // first day, and subsequent buy and sell activities stay included. - const averageInvestmentValue = this.getAverageInvestment({ - grossPerformanceAtStartDate, - sumOfWeightedInvestments, - totalInvestmentDays, - investment: historicalDataItem.totalInvestment - }); + for (const [index, historicalDataItem] of historicalDataItems.entries()) { + const averageInvestmentValue = this.getAverageInvestment( + weightedInvestments[index] + ).toNumber(); const averageInvestmentValueWithCurrencyEffect = - this.getAverageInvestment({ - grossPerformanceAtStartDate: - grossPerformanceWithCurrencyEffectAtStartDate, - investment: historicalDataItem.totalInvestmentValueWithCurrencyEffect, - sumOfWeightedInvestments: sumOfWeightedInvestmentsWithCurrencyEffect, - totalInvestmentDays: totalInvestmentDaysWithCurrencyEffect - }); + this.getAverageInvestment( + weightedInvestmentsWithCurrencyEffect[index] + ).toNumber(); performancePercentagesByDate[historicalDataItem.date] = { dividendInPercentageWithCurrencyEffect: @@ -297,8 +283,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { averageInvestmentValueWithCurrencyEffect : 0 }; - - previousDate = date; } return performancePercentagesByDate; @@ -657,46 +641,35 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { investmentValuesAccumulatedAtStartDateWithCurrencyEffect ); - let average = new Big(0); - let dayCount = 0; + const averageInvestmentItems: AverageInvestmentItem[] = []; - for (let i = chartDates.length - 1; i >= 0; i -= 1) { + for ( + let i = sortedIndex(chartDates, rangeStartDateString); + i < chartDates.length; + i += 1 + ) { const date = chartDates[i]; if (date > rangeEndDateString) { - continue; - } else if (date < rangeStartDateString) { break; } - const isLastChartDateOfRange = - i === chartDates.length - 1 || chartDates[i + 1] > rangeEndDateString; - - // The chart dates are not evenly spaced, and the investment changes - // on a chart date only. Thus the investment of a chart date applies to - // each day until the next chart date. The investment of the last chart - // date of the range counts for this day only. - const investmentDays = isLastChartDateOfRange - ? 1 - : daysUntilNextChartDate[i]; - - if ( - investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && - investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) - ) { - average = average.add( - investmentValuesAccumulatedWithCurrencyEffect[date] - .add(grossPerformanceAtDateRangeStartWithCurrencyEffect) - .mul(investmentDays) - ); - - dayCount += investmentDays; - } + averageInvestmentItems.push({ + daysUntilNextItem: daysUntilNextChartDate[i], + investment: + investmentValuesAccumulatedWithCurrencyEffect[date] ?? new Big(0) + }); } - if (dayCount > 0) { - average = average.div(dayCount); - } + const weightedInvestment = this.getWeightedInvestments({ + grossPerformanceAtStartDate: + grossPerformanceAtDateRangeStartWithCurrencyEffect, + items: averageInvestmentItems + }).at(-1); + + const average = weightedInvestment + ? this.getAverageInvestment(weightedInvestment) + : new Big(0); netPerformanceWithCurrencyEffectMap[dateRange] = netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( @@ -797,23 +770,64 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { investment, sumOfWeightedInvestments, totalInvestmentDays + }: WeightedInvestment) { + // The investment of the current item counts for this day only + if (investment.gt(0)) { + return sumOfWeightedInvestments + .add(investment.add(grossPerformanceAtStartDate)) + .div(totalInvestmentDays + 1); + } + + return totalInvestmentDays > 0 + ? sumOfWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + } + + /** + * Returns the sum of the weighted investments and the number of investment + * days before each item of a date range. The chart dates are not evenly + * spaced, and the investment changes on a chart date only. Thus the + * investment of an item applies to each day until the next item. + * + * The gross performance at the start date of the range is added to each + * investment. Thus the range starts with the value of its first day, and + * subsequent buy and sell activities stay included. + */ + private getWeightedInvestments({ + grossPerformanceAtStartDate, + items }: { - grossPerformanceAtStartDate: number; - investment: number; - sumOfWeightedInvestments: number; - totalInvestmentDays: number; + grossPerformanceAtStartDate: Big; + items: AverageInvestmentItem[]; }) { - // The investment of the current chart date counts for this day only - if (investment > 0) { - return ( - (sumOfWeightedInvestments + - (investment + grossPerformanceAtStartDate)) / - (totalInvestmentDays + 1) - ); + const weightedInvestments: WeightedInvestment[] = []; + + let sumOfWeightedInvestments = new Big(0); + let totalInvestmentDays = 0; + + for (const [index, { investment }] of items.entries()) { + if (index > 0) { + const previousItem = items[index - 1]; + + if (previousItem.investment.gt(0)) { + sumOfWeightedInvestments = sumOfWeightedInvestments.add( + previousItem.investment + .add(grossPerformanceAtStartDate) + .mul(previousItem.daysUntilNextItem) + ); + + totalInvestmentDays += previousItem.daysUntilNextItem; + } + } + + weightedInvestments.push({ + grossPerformanceAtStartDate, + investment, + sumOfWeightedInvestments, + totalInvestmentDays + }); } - return totalInvestmentDays > 0 - ? sumOfWeightedInvestments / totalInvestmentDays - : 0; + return weightedInvestments; } } diff --git a/apps/api/src/app/portfolio/interfaces/average-investment-item.interface.ts b/apps/api/src/app/portfolio/interfaces/average-investment-item.interface.ts new file mode 100644 index 0000000000..fd7f32b37b --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/average-investment-item.interface.ts @@ -0,0 +1,6 @@ +import { Big } from 'big.js'; + +export interface AverageInvestmentItem { + daysUntilNextItem: number; + investment: Big; +} diff --git a/apps/api/src/app/portfolio/interfaces/weighted-investment.interface.ts b/apps/api/src/app/portfolio/interfaces/weighted-investment.interface.ts new file mode 100644 index 0000000000..c3cb42cbc4 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/weighted-investment.interface.ts @@ -0,0 +1,8 @@ +import { Big } from 'big.js'; + +export interface WeightedInvestment { + grossPerformanceAtStartDate: Big; + investment: Big; + sumOfWeightedInvestments: Big; + totalInvestmentDays: number; +}