From 5a69ce761e02e96366ebdba0d9a0e547ce4d743e Mon Sep 17 00:00:00 2001 From: Pedro Moreira Date: Wed, 14 Oct 2026 22:17:58 +0100 Subject: [PATCH] Track accumulated buy values in holding valuation of portfolio calculator --- .../calculator/portfolio-calculator.spec.ts | 154 ++++++++++++++++++ .../calculator/portfolio-calculator.ts | 53 ++++++ .../calculator/roai/portfolio-calculator.ts | 4 + .../accumulated-values.interface.ts | 2 + .../holding-performance.interface.ts | 6 + .../interfaces/holding-valuation.interface.ts | 2 + 6 files changed, 221 insertions(+) create mode 100644 apps/api/src/app/portfolio/calculator/portfolio-calculator.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.spec.ts new file mode 100644 index 0000000000..83bedaaeb1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.spec.ts @@ -0,0 +1,154 @@ +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 { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.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 { 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 getActivityItem({ + date, + itemType, + quantity, + type, + unitPrice +}: { + date: string; + itemType?: PortfolioCalculatorActivityItem['itemType']; + quantity: number; + type: PortfolioCalculatorActivityItem['type']; + unitPrice: number; +}): PortfolioCalculatorActivityItem { + return { + date, + itemType, + type, + assetProfile: { + assetSubClass: 'ETF', + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Test', + symbol: 'TEST' + }, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice), + unitPriceFromMarketData: new Big(unitPrice) + }; +} + +describe('PortfolioCalculator', () => { + 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.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + }); + + describe('get holding valuation', () => { + it('accumulates the buy values without deducting sells', () => { + const { buyValuesAccumulated, buyValuesAccumulatedWithCurrencyEffect } = + portfolioCalculator['getHoldingValuation']({ + activities: [ + getActivityItem({ + date: '2023-12-31', + itemType: 'start', + quantity: 0, + type: 'BUY', + unitPrice: 10 + }), + getActivityItem({ + date: '2024-01-01', + quantity: 100, + type: 'BUY', + unitPrice: 10 + }), + getActivityItem({ + date: '2024-06-01', + quantity: 100, + type: 'SELL', + unitPrice: 10 + }), + getActivityItem({ + date: '2024-06-02', + quantity: 100, + type: 'BUY', + unitPrice: 10 + }), + getActivityItem({ + date: '2024-12-31', + itemType: 'end', + quantity: 0, + type: 'BUY', + unitPrice: 11 + }) + ], + exchangeRates: {}, + unitPriceAtStartDate: undefined + }); + + expect(buyValuesAccumulated).toEqual({ + '2024-01-01': new Big(1000), + '2024-06-01': new Big(1000), + '2024-06-02': new Big(2000), + '2024-12-31': new Big(2000) + }); + + expect(buyValuesAccumulatedWithCurrencyEffect).toEqual( + buyValuesAccumulated + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 2a34aaa644..da710bfe14 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -389,6 +389,8 @@ export abstract class PortfolioCalculator { [assetProfileIdentifier: string]: { averageInvestmentValues: { [date: string]: Big }; averageInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; + buyValuesAccumulated: { [date: string]: Big }; + buyValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; currentValues: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big }; dividendValuesWithCurrencyEffect: { [date: string]: Big }; @@ -425,6 +427,8 @@ export abstract class PortfolioCalculator { averageInvestmentValues, averageInvestmentValuesWithCurrencyEffect, averageInvestmentWithCurrencyEffect, + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, dividendValuesWithCurrencyEffect, @@ -474,6 +478,8 @@ export abstract class PortfolioCalculator { ? { averageInvestmentValues: {}, averageInvestmentValuesWithCurrencyEffect: {}, + buyValuesAccumulated: {}, + buyValuesAccumulatedWithCurrencyEffect: {}, currentValues: {}, currentValuesWithCurrencyEffect: {}, dividendValuesWithCurrencyEffect: {}, @@ -487,6 +493,8 @@ export abstract class PortfolioCalculator { : { averageInvestmentValues, averageInvestmentValuesWithCurrencyEffect, + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, dividendValuesWithCurrencyEffect, @@ -625,6 +633,14 @@ export abstract class PortfolioCalculator { dateString ] ?? new Big(0); + const buyValueAccumulated = + assetProfileValues.buyValuesAccumulated?.[dateString] ?? new Big(0); + + const buyValueAccumulatedWithCurrencyEffect = + assetProfileValues.buyValuesAccumulatedWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + accumulatedValuesByDate[dateString] = { investmentValueWithCurrencyEffect: ( accumulatedValuesByDate[dateString] @@ -638,6 +654,13 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalAverageInvestmentValueWithCurrencyEffect ?? new Big(0) ).add(averageInvestmentValueWithCurrencyEffect), + totalBuyValue: ( + accumulatedValuesByDate[dateString]?.totalBuyValue ?? new Big(0) + ).add(buyValueAccumulated), + totalBuyValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalBuyValueWithCurrencyEffect ?? new Big(0) + ).add(buyValueAccumulatedWithCurrencyEffect), totalCashValueWithCurrencyEffect: ( accumulatedValuesByDate[dateString] ?.totalCashValueWithCurrencyEffect ?? new Big(0) @@ -932,6 +955,8 @@ export abstract class PortfolioCalculator { averageInvestmentValues: {}, averageInvestmentValuesWithCurrencyEffect: {}, averageInvestmentWithCurrencyEffect: new Big(0), + buyValuesAccumulated: {}, + buyValuesAccumulatedWithCurrencyEffect: {}, currentValues: {}, currentValuesWithCurrencyEffect: {}, dividendValuesWithCurrencyEffect: {}, @@ -1000,6 +1025,8 @@ export abstract class PortfolioCalculator { unitPriceAtStartDate: Big | undefined; }): HoldingValuation { const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + const buyValuesAccumulated: { [date: string]: Big } = {}; + const buyValuesAccumulatedWithCurrencyEffect: { [date: string]: Big } = {}; const currentValues: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; const dividendValuesWithCurrencyEffect: { [date: string]: Big } = {}; @@ -1033,6 +1060,10 @@ export abstract class PortfolioCalculator { let totalInvestmentFromBuyTransactions = new Big(0); let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); let totalInvestmentWithCurrencyEffect = new Big(0); + let totalBuyValue = new Big(0); + let totalBuyValueAtStartDate = new Big(0); + let totalBuyValueAtStartDateWithCurrencyEffect = new Big(0); + let totalBuyValueWithCurrencyEffect = new Big(0); let totalQuantity = new Big(0); let totalQuantityFromBuyTransactions = new Big(0); @@ -1127,6 +1158,12 @@ export abstract class PortfolioCalculator { totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( transactionInvestmentWithCurrencyEffect ); + + totalBuyValue = totalBuyValue.plus(transactionInvestment); + + totalBuyValueWithCurrencyEffect = totalBuyValueWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); } else if (activity.type === 'SELL') { if (totalQuantity.gt(0)) { const remainingQuantity = totalQuantity.minus(activity.quantity); @@ -1277,6 +1314,11 @@ export abstract class PortfolioCalculator { if (activity.itemType === 'start') { dividendsAtStartDateWithCurrencyEffect = dividendsWithCurrencyEffect; feesAtStartDate = fees; + totalBuyValueAtStartDate = totalBuyValue; + + totalBuyValueAtStartDateWithCurrencyEffect = + totalBuyValueWithCurrencyEffect; + feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; grossPerformanceAtStartDate = grossPerformance; @@ -1285,6 +1327,15 @@ export abstract class PortfolioCalculator { } if (i > indexOfStartActivity) { + buyValuesAccumulated[activity.date] = totalBuyValue.minus( + totalBuyValueAtStartDate + ); + + buyValuesAccumulatedWithCurrencyEffect[activity.date] = + totalBuyValueWithCurrencyEffect.minus( + totalBuyValueAtStartDateWithCurrencyEffect + ); + currentValues[activity.date] = valueOfInvestment; currentValuesWithCurrencyEffect[activity.date] = @@ -1366,6 +1417,8 @@ export abstract class PortfolioCalculator { } return { + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, dividendValuesWithCurrencyEffect, 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 03e5743d39..292ed30e37 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -298,6 +298,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { activities, dateOfFirstActivity, holdingValuation: { + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, dividendValuesWithCurrencyEffect, @@ -619,6 +621,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { return { averageInvestmentValues, averageInvestmentValuesWithCurrencyEffect, + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, dividendValuesWithCurrencyEffect, diff --git a/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts index 3810cbdeca..3a733890ee 100644 --- a/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts @@ -4,6 +4,8 @@ export interface AccumulatedValues { investmentValueWithCurrencyEffect: Big; totalAverageInvestmentValue: Big; totalAverageInvestmentValueWithCurrencyEffect: Big; + totalBuyValue: Big; + totalBuyValueWithCurrencyEffect: Big; totalCashValueWithCurrencyEffect: Big; totalCurrentValue: Big; totalCurrentValueWithCurrencyEffect: Big; diff --git a/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts index 61da17e71a..42cd82fe1c 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts @@ -11,6 +11,12 @@ export interface HoldingPerformance { [date: string]: Big; }; averageInvestmentWithCurrencyEffect: Big; + buyValuesAccumulated: { + [date: string]: Big; + }; + buyValuesAccumulatedWithCurrencyEffect: { + [date: string]: Big; + }; currentValues: { [date: string]: Big; }; diff --git a/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts index 06ae50005d..5b8471ffd1 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts @@ -3,6 +3,8 @@ import { Big } from 'big.js'; import { HoldingValuationItem } from './holding-valuation-item.interface'; export interface HoldingValuation { + buyValuesAccumulated: { [date: string]: Big }; + buyValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; currentValues: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big }; dividendValuesWithCurrencyEffect: { [date: string]: Big };