From a95f9c1aa5073ea096047258ead081200d62cf46 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:56:56 +0200 Subject: [PATCH] Fix time weighted average investment in ROAI calculator --- ...ator-btcusd-buy-and-sell-partially.spec.ts | 56 ++++++++++- ...-calculator-googl-fee-and-dividend.spec.ts | 16 ++-- ...-calculator-msft-buy-with-dividend.spec.ts | 10 +- .../calculator/roai/portfolio-calculator.ts | 96 +++++++++++++------ 4 files changed, 134 insertions(+), 44 deletions(-) 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 15474a0236..11776d14cc 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 @@ -162,7 +162,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentage: new Big('42.41978276196153750666'), netPerformancePercentageWithCurrencyEffectMap: { '1d': new Big('-0.04016229506406263535'), - max: new Big('41.72313811883729606471'), + max: new Big('41.65910103572163194783'), ytd: new Big('-0.04016229506406263535') }, netPerformanceWithCurrencyEffectMap: { @@ -261,8 +261,8 @@ describe('PortfolioCalculator', () => { max: { date: '2018-01-01', netPerformance: 26458.9121202, - netPerformanceInPercentage: 42.50435329547954, - netPerformanceInPercentageWithCurrencyEffect: 41.72313811883715, + netPerformanceInPercentage: 42.43911719562077, + netPerformanceInPercentageWithCurrencyEffect: 41.65910103572173, netPerformanceWithCurrencyEffect: 26516.2087014, totalInvestmentValueWithCurrencyEffect: 318.54266729999995, valueWithCurrencyEffect: 13298.425356 @@ -278,5 +278,55 @@ describe('PortfolioCalculator', () => { } }); }); + + it.only('with BTCUSD buy and sell partially and fewer chart items', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); + + const environmentConfigurationService = new ConfigurationService(); + + // Fewer chart items give fewer chart dates, which must not change the + // average investment + jest.spyOn(configurationService, 'get').mockImplementation((key) => { + return key === 'MAX_CHART_ITEMS' + ? 50 + : environmentConfigurationService.get(key); + }); + + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: userCurrency, + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['max'] + }); + + expect( + portfolioSnapshot.positions[0] + .netPerformancePercentageWithCurrencyEffectMap + ).toMatchObject({ + max: new Big('41.65910103572163194783') + }); + + // Other weights give another rounding of the floating point numbers + expect(performanceByDateRange.max.netPerformanceInPercentage).toBeCloseTo( + 42.43911719562077, + 10 + ); + + expect( + performanceByDateRange.max.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(41.65910103572173, 10); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts index 18929a680c..f0d689d026 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts @@ -190,9 +190,9 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 5.62, - dividendInPercentageWithCurrencyEffect: 0.01882242615044535, + dividendInPercentageWithCurrencyEffect: 0.01882242615044543, netPerformance: 18.87, - netPerformanceInPercentage: 0.06319914260834586 + netPerformanceInPercentage: 0.06319914260834614 } }); }); @@ -282,9 +282,9 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 5.62, - dividendInPercentageWithCurrencyEffect: 0.01882242615044535, + dividendInPercentageWithCurrencyEffect: 0.01882242615044543, netPerformance: 19.87, - netPerformanceInPercentage: 0.06654832875611194 + netPerformanceInPercentage: 0.06654832875611222 } }); }); @@ -373,9 +373,9 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 0.62, - dividendInPercentageWithCurrencyEffect: 0.002076495411614967, + dividendInPercentageWithCurrencyEffect: 0.002076495411614976, netPerformance: 14.87, - netPerformanceInPercentage: 0.049802398017281556 + netPerformanceInPercentage: 0.049802398017281764 } }); }); @@ -458,11 +458,11 @@ describe('PortfolioCalculator', () => { }, max: { dividendInBaseCurrency: 1.3, - dividendInPercentageWithCurrencyEffect: 0.004353941992095899 + dividendInPercentageWithCurrencyEffect: 0.004353941992095918 }, ytd: { dividendInBaseCurrency: 0.68, - dividendInPercentageWithCurrencyEffect: 0.002002886512915673 + dividendInPercentageWithCurrencyEffect: 0.002002886512915671 } }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 0fdd0b3000..7aa5221dab 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -177,10 +177,10 @@ describe('PortfolioCalculator', () => { max: { date: '2023-07-10', dividendInBaseCurrency: 0.62, - dividendInPercentageWithCurrencyEffect: 0.002076495411614967, + dividendInPercentageWithCurrencyEffect: 0.002076495411614976, netPerformance: 14.87, - netPerformanceInPercentage: 0.049802398017281556, - netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281556, + netPerformanceInPercentage: 0.049802398017281764, + netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281764, netPerformanceWithCurrencyEffect: 14.87, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 @@ -190,8 +190,8 @@ describe('PortfolioCalculator', () => { dividendInBaseCurrency: 0, dividendInPercentageWithCurrencyEffect: 0, netPerformance: -7.6800000000000015, - netPerformanceInPercentage: -0.022620835910577015, - netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577015, + netPerformanceInPercentage: -0.022620835910576995, + netPerformanceInPercentageWithCurrencyEffect: -0.022620835910576995, netPerformanceWithCurrencyEffect: -7.6800000000000015, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 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 a1e41b53a2..6d8e9d15fd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -29,7 +29,6 @@ import { isBefore, isThisYear } from 'date-fns'; -import { sum } from 'lodash-es'; export class RoaiPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance( @@ -210,10 +209,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }: { historicalDataItems: HistoricalDataItem[]; }): { [date: string]: PerformancePercentages } { - const averageInvestmentValues: number[] = []; - const averageInvestmentValuesWithCurrencyEffect: number[] = []; let grossPerformanceAtStartDate: number; let grossPerformanceWithCurrencyEffectAtStartDate: number; + let sumOfWeightedInvestments = 0; + let sumOfWeightedInvestmentsWithCurrencyEffect = 0; + let totalInvestmentDays = 0; + let totalInvestmentDaysWithCurrencyEffect = 0; const performancePercentagesByDate: { [date: string]: PerformancePercentages; @@ -229,34 +230,62 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { grossPerformanceWithCurrencyEffectAtStartDate = historicalDataItem.valueWithCurrencyEffect - historicalDataItem.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. - if (historicalDataItem.totalInvestment > 0) { - averageInvestmentValues.push( - historicalDataItem.totalInvestment + grossPerformanceAtStartDate + } 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( + parseDate(historicalDataItem.date), + parseDate(previousHistoricalDataItem.date) ); - } - if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) { - averageInvestmentValuesWithCurrencyEffect.push( - historicalDataItem.totalInvestmentValueWithCurrencyEffect + - grossPerformanceWithCurrencyEffectAtStartDate - ); + if (previousHistoricalDataItem.totalInvestment > 0) { + sumOfWeightedInvestments += + (previousHistoricalDataItem.totalInvestment + + grossPerformanceAtStartDate) * + daysSincePreviousChartDate; + + totalInvestmentDays += daysSincePreviousChartDate; + } + + if ( + previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect > 0 + ) { + sumOfWeightedInvestmentsWithCurrencyEffect += + (previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect + + grossPerformanceWithCurrencyEffectAtStartDate) * + daysSincePreviousChartDate; + + totalInvestmentDaysWithCurrencyEffect += daysSincePreviousChartDate; + } } + // 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. The + // investment of this chart date counts for this day only. const averageInvestmentValue = - averageInvestmentValues.length > 0 - ? sum(averageInvestmentValues) / averageInvestmentValues.length - : 0; + historicalDataItem.totalInvestment > 0 + ? (sumOfWeightedInvestments + + (historicalDataItem.totalInvestment + + grossPerformanceAtStartDate)) / + (totalInvestmentDays + 1) + : totalInvestmentDays > 0 + ? sumOfWeightedInvestments / totalInvestmentDays + : 0; const averageInvestmentValueWithCurrencyEffect = - averageInvestmentValuesWithCurrencyEffect.length > 0 - ? sum(averageInvestmentValuesWithCurrencyEffect) / - averageInvestmentValuesWithCurrencyEffect.length - : 0; + historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0 + ? (sumOfWeightedInvestmentsWithCurrencyEffect + + (historicalDataItem.totalInvestmentValueWithCurrencyEffect + + grossPerformanceWithCurrencyEffectAtStartDate)) / + (totalInvestmentDaysWithCurrencyEffect + 1) + : totalInvestmentDaysWithCurrencyEffect > 0 + ? sumOfWeightedInvestmentsWithCurrencyEffect / + totalInvestmentDaysWithCurrencyEffect + : 0; performancePercentagesByDate[historicalDataItem.date] = { dividendInPercentageWithCurrencyEffect: @@ -632,6 +661,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let average = new Big(0); let dayCount = 0; + let nextChartDateString: string; for (let i = chartDates.length - 1; i >= 0; i -= 1) { const date = chartDates[i]; @@ -642,17 +672,27 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { break; } + // 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 daysUntilNextChartDate = nextChartDateString + ? differenceInDays(parseDate(nextChartDateString), parseDate(date)) + : 1; + + nextChartDateString = date; + if ( investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) ) { average = average.add( - investmentValuesAccumulatedWithCurrencyEffect[date].add( - grossPerformanceAtDateRangeStartWithCurrencyEffect - ) + investmentValuesAccumulatedWithCurrencyEffect[date] + .add(grossPerformanceAtDateRangeStartWithCurrencyEffect) + .mul(daysUntilNextChartDate) ); - dayCount++; + dayCount += daysUntilNextChartDate; } }