From 1255af450a8d4bb6f8dfcdcb27a1827322ec08f2 Mon Sep 17 00:00:00 2001 From: Pedro Moreira Date: Wed, 14 Oct 2026 22:21:42 +0100 Subject: [PATCH] Implement performance percentages of ROI portfolio calculator --- .../calculator/mwr/portfolio-calculator.ts | 9 +- .../calculator/portfolio-calculator.ts | 10 + .../calculator/roai/portfolio-calculator.ts | 4 + .../roi/portfolio-calculator.spec.ts | 279 ++++++++++++++++++ .../calculator/roi/portfolio-calculator.ts | 118 +++++++- .../calculator/twr/portfolio-calculator.ts | 9 +- .../historical-data-item.interface.ts | 2 + 7 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index f83b175128..38d837a1aa 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -1,7 +1,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -22,6 +25,10 @@ export class MwrPortfolioCalculator extends PortfolioCalculator { throw new Error('Method not implemented.'); } + protected getAdditionalHistoricalDataItemValues(): Partial { + throw new Error('Method not implemented.'); + } + protected getHoldingPerformance({}: { chartDates: string[]; end: Date; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index da710bfe14..1a754f20ad 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -735,6 +735,7 @@ export abstract class PortfolioCalculator { return { ...performancePercentagesByDate[date], + ...this.getAdditionalHistoricalDataItemValues(values), date, dividendInBaseCurrency: totalDividendValueWithCurrencyEffect.toNumber(), investmentValueWithCurrencyEffect: @@ -912,6 +913,15 @@ export abstract class PortfolioCalculator { }); } + /** + * Returns the values of a historical data item which the performance + * calculation type requires in addition, e.g. to calculate the performance + * percentages of a date range + */ + protected abstract getAdditionalHistoricalDataItemValues( + accumulatedValues: AccumulatedValues + ): Partial; + public getDataProviderInfos() { return this.dataProviderInfos; } 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 292ed30e37..93c90bee2d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -250,6 +250,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { return performancePercentagesByDate; } + protected getAdditionalHistoricalDataItemValues() { + return {}; + } + protected getHoldingPerformance({ chartDates, dataSource, diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.spec.ts new file mode 100644 index 0000000000..7942410648 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.spec.ts @@ -0,0 +1,279 @@ +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +import { PortfolioCalculator } from '../portfolio-calculator'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +function getAccumulatedValues({ + totalBuyValue, + totalDividendValue = 0, + totalNetPerformanceValue +}: { + totalBuyValue: number; + totalDividendValue?: number; + totalNetPerformanceValue: number; +}): AccumulatedValues { + return { + investmentValueWithCurrencyEffect: new Big(0), + totalAverageInvestmentValue: new Big(0), + totalAverageInvestmentValueWithCurrencyEffect: new Big(0), + totalBuyValue: new Big(totalBuyValue), + totalBuyValueWithCurrencyEffect: new Big(totalBuyValue), + totalCashValueWithCurrencyEffect: new Big(0), + totalCurrentValue: new Big(0), + totalCurrentValueWithCurrencyEffect: new Big(0), + totalDividendValueWithCurrencyEffect: new Big(totalDividendValue), + totalInvestmentValue: new Big(0), + totalInvestmentValueWithCurrencyEffect: new Big(0), + totalNetPerformanceValue: new Big(totalNetPerformanceValue), + totalNetPerformanceValueWithCurrencyEffect: new Big( + totalNetPerformanceValue + ), + totalNetWorthValueWithCurrencyEffect: new Big(0) + }; +} + +function getHistoricalDataItem({ + date, + dividendInBaseCurrency = 0, + netPerformance, + totalBuyValue, + value +}: { + date: string; + dividendInBaseCurrency?: number; + netPerformance: number; + totalBuyValue: number; + value: number; +}): HistoricalDataItem { + return { + date, + dividendInBaseCurrency, + netPerformance, + totalBuyValue, + value, + netPerformanceWithCurrencyEffect: netPerformance, + totalBuyValueWithCurrencyEffect: totalBuyValue, + valueWithCurrencyEffect: value + }; +} + +describe('RoiPortfolioCalculator', () => { + let portfolioCalculator: PortfolioCalculator; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + const portfolioCalculatorFactory = new PortfolioCalculatorFactory( + new ConfigurationService(), + new CurrentRateService(null, null, null), + new ExchangeRateDataService(null, null, null, null), + new PortfolioSnapshotService(null, null), + new RedisCacheService(null, null) + ); + + portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + }); + + describe('calculate performance percentages', () => { + it('divides the net performance by the total buy value', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentages' + ]({ + accumulatedValuesByDate: { + '2024-12-31': getAccumulatedValues({ + totalBuyValue: 10000, + totalDividendValue: 100, + totalNetPerformanceValue: 500 + }) + } + }); + + expect(performancePercentagesByDate['2024-12-31']).toEqual({ + dividendInPercentageWithCurrencyEffect: 0.01, + netPerformanceInPercentage: 0.05, + netPerformanceInPercentageWithCurrencyEffect: 0.05 + }); + }); + + it('returns 0 without a buy', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentages' + ]({ + accumulatedValuesByDate: { + '2024-12-31': getAccumulatedValues({ + totalBuyValue: 0, + totalNetPerformanceValue: 0 + }) + } + }); + + expect(performancePercentagesByDate['2024-12-31']).toEqual({ + dividendInPercentageWithCurrencyEffect: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0 + }); + }); + }); + + describe('calculate performance percentages for date range', () => { + it('with buys only within the date range', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentagesForDateRange' + ]({ + historicalDataItems: [ + getHistoricalDataItem({ + date: '2023-12-31', + netPerformance: 0, + totalBuyValue: 0, + value: 0 + }), + getHistoricalDataItem({ + date: '2024-01-01', + netPerformance: 0, + totalBuyValue: 1000, + value: 1000 + }), + getHistoricalDataItem({ + date: '2024-12-31', + netPerformance: 500, + totalBuyValue: 10000, + value: 10500 + }) + ] + }); + + // 500 / (0 + 10000 - 0) = 0.05 + expect( + performancePercentagesByDate['2024-12-31'].netPerformanceInPercentage + ).toBeCloseTo(0.05, 10); + + expect( + performancePercentagesByDate['2024-12-31'] + .netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.05, 10); + }); + + it('with a value at the start date of the date range', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentagesForDateRange' + ]({ + historicalDataItems: [ + // The netPerformance is already rebased to the start date + getHistoricalDataItem({ + date: '2024-01-01', + netPerformance: 0, + totalBuyValue: 1000, + value: 2000 + }), + getHistoricalDataItem({ + date: '2024-12-31', + dividendInBaseCurrency: 30, + netPerformance: 300, + totalBuyValue: 2000, + value: 3300 + }) + ] + }); + + // 300 / (2000 + 2000 - 1000) = 0.1 + expect( + performancePercentagesByDate['2024-12-31'].netPerformanceInPercentage + ).toBeCloseTo(0.1, 10); + + // 30 / (2000 + 2000 - 1000) = 0.01 + expect( + performancePercentagesByDate['2024-12-31'] + .dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0.01, 10); + }); + + it('returns 0 without a value and without a buy', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentagesForDateRange' + ]({ + historicalDataItems: [ + getHistoricalDataItem({ + date: '2024-01-01', + netPerformance: 0, + totalBuyValue: 0, + value: 0 + }), + getHistoricalDataItem({ + date: '2024-12-31', + netPerformance: 0, + totalBuyValue: 0, + value: 0 + }) + ] + }); + + expect(performancePercentagesByDate['2024-12-31']).toEqual({ + dividendInPercentageWithCurrencyEffect: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0 + }); + }); + }); + + describe('get additional historical data item values', () => { + it('returns the total buy values', () => { + expect( + portfolioCalculator['getAdditionalHistoricalDataItemValues']( + getAccumulatedValues({ + totalBuyValue: 10000, + totalNetPerformanceValue: 500 + }) + ) + ).toEqual({ + totalBuyValue: 10000, + totalBuyValueWithCurrencyEffect: 10000 + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts index 862febf5db..40b0ac4a4f 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,25 +1,125 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; +import { Big } from 'big.js'; + export class RoiPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance(): PortfolioSnapshot { throw new Error('Method not implemented.'); } - protected calculatePerformancePercentages(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentages({ + accumulatedValuesByDate + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }): { [date: string]: PerformancePercentages } { + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + // The portfolio snapshot starts before the first activity, thus the + // invested capital of each date is the total buy value + for (const [ + date, + { + totalBuyValue, + totalBuyValueWithCurrencyEffect, + totalDividendValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + } + ] of Object.entries(accumulatedValuesByDate)) { + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + totalBuyValueWithCurrencyEffect.eq(0) + ? 0 + : totalDividendValueWithCurrencyEffect + .div(totalBuyValueWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: totalBuyValue.eq(0) + ? 0 + : totalNetPerformanceValue.div(totalBuyValue).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + totalBuyValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalBuyValueWithCurrencyEffect) + .toNumber() + }; + } + + return performancePercentagesByDate; } - protected calculatePerformancePercentagesForDateRange(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentagesForDateRange({ + historicalDataItems + }: { + historicalDataItems: HistoricalDataItem[]; + }): { [date: string]: PerformancePercentages } { + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + if (historicalDataItems.length === 0) { + return performancePercentagesByDate; + } + + // The invested capital of the date range is the value at its start date + // plus the value of each buy within the date range + const [firstHistoricalDataItem] = historicalDataItems; + + for (const historicalDataItem of historicalDataItems) { + const investedCapital = new Big(firstHistoricalDataItem.value ?? 0) + .plus(historicalDataItem.totalBuyValue ?? 0) + .minus(firstHistoricalDataItem.totalBuyValue ?? 0); + + const investedCapitalWithCurrencyEffect = new Big( + firstHistoricalDataItem.valueWithCurrencyEffect ?? 0 + ) + .plus(historicalDataItem.totalBuyValueWithCurrencyEffect ?? 0) + .minus(firstHistoricalDataItem.totalBuyValueWithCurrencyEffect ?? 0); + + performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.gt(0) + ? new Big(historicalDataItem.dividendInBaseCurrency ?? 0) + .div(investedCapitalWithCurrencyEffect) + .toNumber() + : 0, + netPerformanceInPercentage: investedCapital.gt(0) + ? new Big(historicalDataItem.netPerformance ?? 0) + .div(investedCapital) + .toNumber() + : 0, + netPerformanceInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.gt(0) + ? new Big(historicalDataItem.netPerformanceWithCurrencyEffect ?? 0) + .div(investedCapitalWithCurrencyEffect) + .toNumber() + : 0 + }; + } + + return performancePercentagesByDate; + } + + protected getAdditionalHistoricalDataItemValues({ + totalBuyValue, + totalBuyValueWithCurrencyEffect + }: AccumulatedValues): Partial { + return { + totalBuyValue: totalBuyValue.toNumber(), + totalBuyValueWithCurrencyEffect: + totalBuyValueWithCurrencyEffect.toNumber() + }; } protected getHoldingPerformance({}: { diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index cad53c31ab..9434c368cc 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -1,7 +1,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -22,6 +25,10 @@ export class TwrPortfolioCalculator extends PortfolioCalculator { throw new Error('Method not implemented.'); } + protected getAdditionalHistoricalDataItemValues(): Partial { + throw new Error('Method not implemented.'); + } + protected getHoldingPerformance({}: { chartDates: string[]; end: Date; 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 a733837d52..499d6bbdbb 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -13,6 +13,8 @@ export interface HistoricalDataItem { netWorth?: number; netWorthInPercentage?: number; quantity?: number; + totalBuyValue?: number; + totalBuyValueWithCurrencyEffect?: number; totalCashInBaseCurrency?: number; totalInvestment?: number; totalInvestmentValueWithCurrencyEffect?: number;