From 12cc8144d3f3386699c38a83e4ec57064af19d2f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:51:52 +0200 Subject: [PATCH 1/6] Implement ROI portfolio calculator --- ...tfolio-calculator-baln-buy-and-buy.spec.ts | 218 +++++++ ...folio-calculator-baln-buy-and-sell.spec.ts | 255 ++++++++ .../roi/portfolio-calculator-baln-buy.spec.ts | 340 ++++++++++ ...ator-btcusd-buy-and-sell-partially.spec.ts | 315 +++++++++ ...-calculator-msft-buy-with-dividend.spec.ts | 235 +++++++ .../calculator/roi/portfolio-calculator.ts | 599 +++++++++++++++++- 6 files changed, 1948 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts new file mode 100644 index 0000000000..776d9f077f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -0,0 +1,218 @@ +import { + activityDummyData, + assetProfileDummyData, + 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 { 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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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; + }) + }; +}); + +describe('PortfolioCalculator (ROI)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('595.6'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averageInvestment: new Big('559'), + averageInvestmentWithCurrencyEffect: new Big('559'), + averagePrice: new Big('139.75'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('36.6'), + grossPerformancePercentage: new Big('0.0654740608228980322'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.0654740608228980322' + ), + grossPerformanceWithCurrencyEffect: new Big('36.6'), + investment: new Big('559'), + investmentWithCurrencyEffect: new Big('559'), + netPerformance: new Big('33.4'), + netPerformancePercentage: new Big('0.05974955277280858676'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.05974955277280858676') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('33.4') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('4'), + symbol: 'BALN.SW', + tags: [], + valueInBaseCurrency: new Big('595.6') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('559'), + totalInvestmentWithCurrencyEffect: new Big('559'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 33.4, + netPerformanceInPercentage: 0.059749552772808585, + netPerformanceInPercentageWithCurrencyEffect: 0.059749552772808585, + netPerformanceWithCurrencyEffect: 33.4, + totalInvestment: 559, + totalInvestmentValueWithCurrencyEffect: 559 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('559') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 559 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 559 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts new file mode 100644 index 0000000000..0e1099da87 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -0,0 +1,255 @@ +import { + activityDummyData, + assetProfileDummyData, + getPerformanceByDateRange, + 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 { 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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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; + }) + }; +}); + +describe('PortfolioCalculator (ROI)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['1d', 'max', 'ytd'] + }); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averageInvestment: new Big('285.8'), + averageInvestmentWithCurrencyEffect: new Big('285.8'), + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.0440867739678096571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.0440867739678096571' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); + + expect(performanceByDateRange).toMatchObject({ + '1d': { + date: '2021-12-18', + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestmentValueWithCurrencyEffect: 0, + valueWithCurrencyEffect: 0 + }, + max: { + date: '2021-12-18', + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550735, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550735, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestmentValueWithCurrencyEffect: 0, + valueWithCurrencyEffect: 0 + }, + ytd: { + date: '2021-12-18', + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550735, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550735, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestmentValueWithCurrencyEffect: 0, + valueWithCurrencyEffect: 0 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts new file mode 100644 index 0000000000..54d339f544 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts @@ -0,0 +1,340 @@ +import { + activityDummyData, + assetProfileDummyData, + 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 { 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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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; + }) + }; +}); + +describe('PortfolioCalculator (ROI)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataDates = portfolioSnapshot.historicalData.map( + ({ date }) => { + return date; + } + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averageInvestment: new Big('273.2'), + averageInvestmentWithCurrencyEffect: new Big('273.2'), + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-30', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1.55'), + feeInBaseCurrency: new Big('1.55'), + grossPerformance: new Big('24.6'), + grossPerformancePercentage: new Big('0.09004392386530014641'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.09004392386530014641' + ), + grossPerformanceWithCurrencyEffect: new Big('24.6'), + investment: new Big('273.2'), + investmentWithCurrencyEffect: new Big('273.2'), + netPerformance: new Big('23.05'), + netPerformancePercentage: new Big('0.08437042459736456808'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.08437042459736456808') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period + '1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + '5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period + wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period + ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55 + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('2'), + symbol: 'BALN.SW', + tags: [], + valueInBaseCurrency: new Big('297.8') + } + ], + totalFeesWithCurrencyEffect: new Big('1.55'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('273.2'), + totalInvestmentWithCurrencyEffect: new Big('273.2'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(historicalDataDates).not.toContain('2021-01-01'); + expect(historicalDataDates).not.toContain('2021-12-31'); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + date: '2021-12-18', + netPerformance: 23.05, + netPerformanceInPercentage: 0.08437042459736457, + netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, + netPerformanceWithCurrencyEffect: 23.05, + totalInvestment: 273.2, + totalInvestmentValueWithCurrencyEffect: 273.2 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-30', investment: new Big('273.2') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 273.2 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 273.2 } + ]); + }); + + it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); + + it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); + + it.only('with BALN.SW buy (on a date without historical market data)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnDateWithoutMarketData = + portfolioSnapshot.historicalData.find(({ date }) => { + return date === '2021-12-05'; + }); + + // No historical market data on 2021-12-05: carry forward the closing + // price of 2021-11-30, which is the date of the activity + expect(snapshotOnDateWithoutMarketData?.value).toEqual(273.2); // 2 * 136.6 + expect( + snapshotOnDateWithoutMarketData?.netPerformanceWithCurrencyEffect + ).toEqual(-1.55); // 2 * (136.6 - 136.6) - 1.55 + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts new file mode 100644 index 0000000000..564d264795 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -0,0 +1,315 @@ +import { + activityDummyData, + assetProfileDummyData, + getPerformanceByDateRange, + 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 { 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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator (ROI)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, + date: new Date('2015-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 320.43 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, + date: new Date('2017-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'SELL', + unitPriceInAssetProfileCurrency: 14156.4 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['1d', 'max', 'ytd'] + }); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('13298.425356'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averageInvestment: new Big('624.0246078'), + averageInvestmentWithCurrencyEffect: new Big( + '637.085334599999935914' + ), + averagePrice: new Big('320.43'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2015-01-01', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + grossPerformance: new Big('27172.74').mul(0.97373), + grossPerformancePercentage: new Big('42.40043067128546016291'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '41.62112555619955206456' + ), + grossPerformanceWithCurrencyEffect: new Big( + '26516.208701400000064086' + ), + investment: new Big('320.43').mul(0.97373), + investmentWithCurrencyEffect: new Big('318.542667299999967957'), + marketPrice: 13657.2, + marketPriceInBaseCurrency: 13298.425356, + netPerformance: new Big('27172.74').mul(0.97373), + netPerformancePercentage: new Big('42.40043067128546016291'), + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big('-0.04016229506406263535'), + max: new Big('41.62112555619955206456'), + ytd: new Big('-0.04016229506406263535') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('-556.443324'), + max: new Big('26516.208701400000064086'), + ytd: new Big('-556.443324') + }, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + valueInBaseCurrency: new Big('13298.425356') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('320.43').mul(0.97373), + totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('27172.74').mul(0.97373).toNumber(), + netPerformanceInPercentage: 42.40043067128546, + netPerformanceInPercentageWithCurrencyEffect: 41.62112555619955, + netPerformanceWithCurrencyEffect: 26516.2087014, + totalInvestment: 312.0123039, + totalInvestmentValueWithCurrencyEffect: 318.54266729999995 + }) + ); + + expect(investments).toEqual([ + { date: '2015-01-01', investment: new Big('640.86') }, + { date: '2017-12-31', investment: new Big('320.43') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2014-12-01', investment: 0 }, + { date: '2015-01-01', investment: 637.0853345999999 }, + { date: '2015-02-01', investment: 0 }, + { date: '2015-03-01', investment: 0 }, + { date: '2015-04-01', investment: 0 }, + { date: '2015-05-01', investment: 0 }, + { date: '2015-06-01', investment: 0 }, + { date: '2015-07-01', investment: 0 }, + { date: '2015-08-01', investment: 0 }, + { date: '2015-09-01', investment: 0 }, + { date: '2015-10-01', investment: 0 }, + { date: '2015-11-01', investment: 0 }, + { date: '2015-12-01', investment: 0 }, + { date: '2016-01-01', investment: 0 }, + { date: '2016-02-01', investment: 0 }, + { date: '2016-03-01', investment: 0 }, + { date: '2016-04-01', investment: 0 }, + { date: '2016-05-01', investment: 0 }, + { date: '2016-06-01', investment: 0 }, + { date: '2016-07-01', investment: 0 }, + { date: '2016-08-01', investment: 0 }, + { date: '2016-09-01', investment: 0 }, + { date: '2016-10-01', investment: 0 }, + { date: '2016-11-01', investment: 0 }, + { date: '2016-12-01', investment: 0 }, + { date: '2017-01-01', investment: 0 }, + { date: '2017-02-01', investment: 0 }, + { date: '2017-03-01', investment: 0 }, + { date: '2017-04-01', investment: 0 }, + { date: '2017-05-01', investment: 0 }, + { date: '2017-06-01', investment: 0 }, + { date: '2017-07-01', investment: 0 }, + { date: '2017-08-01', investment: 0 }, + { date: '2017-09-01', investment: 0 }, + { date: '2017-10-01', investment: 0 }, + { date: '2017-11-01', investment: 0 }, + { date: '2017-12-01', investment: -318.54266729999995 }, + { date: '2018-01-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2014-01-01', investment: 0 }, + { date: '2015-01-01', investment: 637.0853345999999 }, + { date: '2016-01-01', investment: 0 }, + { date: '2017-01-01', investment: -318.54266729999995 }, + { date: '2018-01-01', investment: 0 } + ]); + + expect(performanceByDateRange).toMatchObject({ + '1d': { + date: '2018-01-01', + netPerformance: -486.0860160000011, + netPerformanceInPercentage: -0.035263202509112565, + netPerformanceInPercentageWithCurrencyEffect: -0.040162295064062624, + netPerformanceWithCurrencyEffect: -556.4433239999998, + totalInvestmentValueWithCurrencyEffect: 318.54266729999995, + valueWithCurrencyEffect: 13298.425356 + }, + max: { + date: '2018-01-01', + netPerformance: 26458.9121202, + netPerformanceInPercentage: 42.400430671285456, + netPerformanceInPercentageWithCurrencyEffect: 41.62112555619955, + netPerformanceWithCurrencyEffect: 26516.2087014, + totalInvestmentValueWithCurrencyEffect: 318.54266729999995, + valueWithCurrencyEffect: 13298.425356 + }, + ytd: { + date: '2018-01-01', + netPerformance: -486.0860160000011, + netPerformanceInPercentage: -0.035263202509112565, + netPerformanceInPercentageWithCurrencyEffect: -0.040162295064062624, + netPerformanceWithCurrencyEffect: -556.4433239999998, + totalInvestmentValueWithCurrencyEffect: 318.54266729999995, + valueWithCurrencyEffect: 13298.425356 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 0000000000..8496f095c9 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,235 @@ +import { + activityDummyData, + assetProfileDummyData, + getPerformanceByDateRange, + 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 { 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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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; + }) + }; +}); + +describe('PortfolioCalculator (ROI)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['1d', 'max', 'ytd'] + }); + + expect(portfolioSnapshot).toMatchObject({ + dividendYieldPercent: new Big('0.001144362748184'), + dividendYieldPercentWithCurrencyEffect: new Big('0.001144362748184'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-09-16', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + dividendYieldPercent: new Big('0.001144362748184'), + dividendYieldPercentWithCurrencyEffect: new Big( + '0.001144362748184' + ), + fee: new Big('19'), + grossPerformance: new Big('33.87'), + grossPerformancePercentage: new Big('0.11343693482483756447'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.11343693482483756447' + ), + grossPerformanceWithCurrencyEffect: new Big('33.87'), + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + netPerformance: new Big('14.87'), + netPerformancePercentage: new Big('0.04980239801728180052'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.04980239801728180052') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('-5.39'), + '5y': new Big('14.87'), + max: new Big('14.87'), + wtd: new Big('-5.39') + }, + quantity: new Big('1'), + symbol: 'MSFT', + tags: [] + } + ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + totalInvestment: 298.58, + totalInvestmentValueWithCurrencyEffect: 298.58 + }) + ); + + expect(performanceByDateRange).toMatchObject({ + '1d': { + date: '2023-07-10', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, + netPerformance: -5.390000000000002, + netPerformanceInPercentage: -0.015983630864124316, + netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124316, + netPerformanceWithCurrencyEffect: -5.390000000000002, + totalInvestmentValueWithCurrencyEffect: 298.58, + valueWithCurrencyEffect: 331.83 + }, + max: { + date: '2023-07-10', + dividendInBaseCurrency: 0.62, + dividendInPercentageWithCurrencyEffect: 0.0020764954116149776, + netPerformance: 14.87, + netPerformanceInPercentage: 0.0498023980172818, + netPerformanceInPercentageWithCurrencyEffect: 0.0498023980172818, + netPerformanceWithCurrencyEffect: 14.87, + totalInvestmentValueWithCurrencyEffect: 298.58, + valueWithCurrencyEffect: 331.83 + }, + ytd: { + date: '2023-07-10', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, + netPerformance: -7.6800000000000015, + netPerformanceInPercentage: -0.022620835910577012, + netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577012, + netPerformanceWithCurrencyEffect: -7.6800000000000015, + totalInvestmentValueWithCurrencyEffect: 298.58, + valueWithCurrencyEffect: 331.83 + } + }); + }); + }); +}); 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..8400c0167f 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,28 +1,291 @@ 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 { 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 { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + parseDate +} from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; +import { + PortfolioSnapshot, + PortfolioSnapshotHolding +} from '@ghostfolio/common/models'; +import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; +import { Big } from 'big.js'; +import { + differenceInDays, + eachYearOfInterval, + format, + isBefore, + isThisYear +} from 'date-fns'; + +/** + * Return on Investment (ROI): the gain divided by the invested capital, without + * time weighting. The invested capital of a holding is the sum of its buy + * amounts. The invested capital of a date range is the value at the start of + * the range plus the buy amounts during the range. + * + * The invested capital fills the average investment fields of the holding + * performance, which the base class sums per date. + */ export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance(): PortfolioSnapshot { - throw new Error('Method not implemented.'); + protected calculateOverallPerformance( + positions: PortfolioCalculatorHolding[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let hasErrors = false; + let totalFeesWithCurrencyEffect = new Big(0); + const totalInterestWithCurrencyEffect = new Big(0); + let totalInvestedCapital = new Big(0); + let totalInvestedCapitalWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions) { + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (!currentPosition.includeInPerformance) { + continue; + } + + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if ( + !currentPosition.grossPerformance && + !currentPosition.quantity.eq(0) + ) { + hasErrors = true; + } + + if (currentPosition.averageInvestment) { + totalInvestedCapital = totalInvestedCapital.plus( + currentPosition.averageInvestment + ); + + totalInvestedCapitalWithCurrencyEffect = + totalInvestedCapitalWithCurrencyEffect.plus( + currentPosition.averageInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + this.logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})` + ); + + hasErrors = true; + } + } + + const dateOfFirstActivity = this.getStartDate(); + + const daysInMarket = dateOfFirstActivity + ? differenceInDays(new Date(), dateOfFirstActivity) + : 0; + + // Take the dividend from the same source as the portfolio summary, so + // that the response shows one dividend only + const totalDividendInBaseCurrency = + this.getDividendInBaseCurrencyOfHoldings(positions); + + const hasDividendWithoutInvestedCapital = + this.hasDividendWithoutInvestedCapital(positions); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: + hasDividendWithoutInvestedCapital || totalInvestedCapital.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(totalInvestedCapital) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: + hasDividendWithoutInvestedCapital || + totalInvestedCapitalWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div( + totalInvestedCapitalWithCurrencyEffect + ) + }); + + return { + currentValueInBaseCurrency, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + createdAt: new Date(), + errors: [], + historicalData: [], + totalCashInBaseCurrency: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }; } - protected calculatePerformancePercentages(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentages({ + accumulatedValuesByDate, + holdings + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + holdings: PortfolioSnapshotHolding[]; + }): { [date: string]: PerformancePercentages } { + const hasDividendWithoutInvestedCapital = + this.hasDividendWithoutInvestedCapital(holdings); + + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + for (const [ + date, + { + totalAverageInvestmentValue: totalInvestedCapital, + totalAverageInvestmentValueWithCurrencyEffect: + totalInvestedCapitalWithCurrencyEffect, + totalDividendValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + } + ] of Object.entries(accumulatedValuesByDate)) { + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + hasDividendWithoutInvestedCapital || + totalInvestedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalDividendValueWithCurrencyEffect + .div(totalInvestedCapitalWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: totalInvestedCapital.eq(0) + ? 0 + : totalNetPerformanceValue.div(totalInvestedCapital).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + totalInvestedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalInvestedCapitalWithCurrencyEffect) + .toNumber() + }; + } + + return performancePercentagesByDate; } - protected calculatePerformancePercentagesForDateRange(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentagesForDateRange({ + historicalDataItems, + holdings + }: { + historicalDataItems: HistoricalDataItem[]; + holdings: PortfolioSnapshotHolding[]; + }): { [date: string]: PerformancePercentages } { + const hasDividendWithoutInvestedCapital = + this.hasDividendWithoutInvestedCapital(holdings); + + let investedCapital = 0; + let investedCapitalWithCurrencyEffect = 0; + + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + for (const [index, historicalDataItem] of historicalDataItems.entries()) { + if (index === 0) { + // The invested capital of the date range starts with the value at + // the start date + investedCapital = historicalDataItem.value; + investedCapitalWithCurrencyEffect = + historicalDataItem.valueWithCurrencyEffect; + } else { + // The buy amounts during the date range increase the invested capital. + // The sell amounts do not reduce it, so that a sold holding keeps its + // share of the gain. + const investmentChange = + historicalDataItem.totalInvestment - + historicalDataItems[index - 1].totalInvestment; + + if (investmentChange > 0) { + investedCapital += investmentChange; + } + + if (historicalDataItem.investmentValueWithCurrencyEffect > 0) { + investedCapitalWithCurrencyEffect += + historicalDataItem.investmentValueWithCurrencyEffect; + } + } + + performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: + !hasDividendWithoutInvestedCapital && + investedCapitalWithCurrencyEffect > 0 + ? historicalDataItem.dividendInBaseCurrency / + investedCapitalWithCurrencyEffect + : 0, + netPerformanceInPercentage: + investedCapital > 0 + ? historicalDataItem.netPerformance / investedCapital + : 0, + netPerformanceInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect > 0 + ? historicalDataItem.netPerformanceWithCurrencyEffect / + investedCapitalWithCurrencyEffect + : 0 + }; + } + + return performancePercentagesByDate; } - protected getHoldingPerformance({}: { + protected getHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; @@ -31,10 +294,318 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { - throw new Error('Method not implemented.'); + const investedCapitalValues: { [date: string]: Big } = {}; + const investedCapitalValuesWithCurrencyEffect: { [date: string]: Big } = {}; + + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + let activities: PortfolioCalculatorActivityItem[] = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; + + const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; + + if (activities.length <= 0) { + return this.getEmptyHoldingPerformance(); + } + + // The dividends, the interest and the liabilities are derived from the + // activities only. Accumulate them upfront so that they survive the bail + // out for symbols without a market price below. + const { + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency + } = this.getTotalsFromActivities({ activities, exchangeRates }); + + const dateOfFirstActivity = parseDate(activities[0].date); + + const endDateString = format(end, DATE_FORMAT); + const startDateString = format(start, DATE_FORMAT); + + const unitPriceAtStartDate = + marketSymbolMap[startDateString]?.[assetProfileIdentifier]; + + const unitPriceAtEndDate = this.getUnitPriceAtEndDate({ + activities, + dataSource, + isCash, + marketPriceAtEndDate: + marketSymbolMap[endDateString]?.[assetProfileIdentifier] + }); + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start)) + ) { + // A missing market price can only affect the quantity which is held. The + // dividends, the interest and the liabilities do not hold any quantity + // and are therefore not in error. + const hasActivitiesWithQuantity = activities.some(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }); + + return { + ...this.getEmptyHoldingPerformance(), + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency, + hasErrors: hasActivitiesWithQuantity + }; + } + + activities = this.getActivitiesWithMarketPrices({ + activities, + chartDates, + endDateString, + marketSymbolMap, + startDateString, + unitPriceAtEndDate, + unitPriceAtStartDate, + assetProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + } + }); + + const { + currentValues, + currentValuesWithCurrencyEffect, + initialValue, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + items, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect + } = this.getHoldingValuation({ + activities, + exchangeRates, + unitPriceAtStartDate + }); + + const indexOfStartActivity = items.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + + const indexOfEndActivity = items.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + let investedCapital = new Big(0); + let investedCapitalWithCurrencyEffect = new Big(0); + + for (let i = indexOfStartActivity + 1; i <= indexOfEndActivity; i += 1) { + const item = items[i]; + + // A buy amount is a positive transaction investment. A sell amount does + // not reduce the invested capital, so that a sold holding keeps its + // share of the gain. + if (item.transactionInvestment.gt(0)) { + investedCapital = investedCapital.plus(item.transactionInvestment); + + investedCapitalWithCurrencyEffect = + investedCapitalWithCurrencyEffect.plus( + item.transactionInvestmentWithCurrencyEffect + ); + } + + investedCapitalValues[item.date] = investedCapital; + + investedCapitalValuesWithCurrencyEffect[item.date] = + investedCapitalWithCurrencyEffect; + } + + const { + fees: feesAtStartDate, + grossPerformance: grossPerformanceAtStartDate, + grossPerformanceWithCurrencyEffect: + grossPerformanceAtStartDateWithCurrencyEffect + } = items[indexOfStartActivity]; + + const { + fees, + grossPerformance, + grossPerformanceWithCurrencyEffect, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + quantity: totalQuantity + } = items[indexOfEndActivity]; + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + + const totalNetPerformance = totalGrossPerformance.minus( + fees.minus(feesAtStartDate) + ); + + const grossPerformancePercentage = investedCapital.gt(0) + ? totalGrossPerformance.div(investedCapital) + : new Big(0); + + const grossPerformancePercentageWithCurrencyEffect = + investedCapitalWithCurrencyEffect.gt(0) + ? totalGrossPerformanceWithCurrencyEffect.div( + investedCapitalWithCurrencyEffect + ) + : new Big(0); + + const netPerformancePercentage = investedCapital.gt(0) + ? totalNetPerformance.div(investedCapital) + : new Big(0); + + const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: investedCapital.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(investedCapital) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: investedCapitalWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(investedCapitalWithCurrencyEffect) + }); + + const netPerformancePercentageWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + const netPerformanceWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + for (const dateRange of [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end, start }) + .filter((date) => { + return !isThisYear(date); + }) + .map((date) => { + return format(date, 'yyyy'); + }) + ] as DateRange[]) { + const dateInterval = getIntervalFromDateRange({ dateRange }); + const endDate = dateInterval.endDate; + let startDate = dateInterval.startDate; + + if (isBefore(startDate, start)) { + startDate = start; + } + + const rangeEndDateString = format(endDate, DATE_FORMAT); + const rangeStartDateString = format(startDate, DATE_FORMAT); + + // The invested capital of the date range is the value at the start date + // plus the buy amounts during the range + let investedCapitalOfDateRangeWithCurrencyEffect = + currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); + + for (const item of items) { + if ( + item.date > rangeStartDateString && + item.date <= rangeEndDateString && + item.transactionInvestmentWithCurrencyEffect.gt(0) + ) { + investedCapitalOfDateRangeWithCurrencyEffect = + investedCapitalOfDateRangeWithCurrencyEffect.plus( + item.transactionInvestmentWithCurrencyEffect + ); + } + } + + netPerformanceWithCurrencyEffectMap[dateRange] = + netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0); + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = + investedCapitalOfDateRangeWithCurrencyEffect.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div( + investedCapitalOfDateRangeWithCurrencyEffect + ) + : new Big(0); + } + + return { + currentValues, + currentValuesWithCurrencyEffect, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilitiesInBaseCurrency, + averageInvestment: investedCapital, + averageInvestmentValues: investedCapitalValues, + averageInvestmentValuesWithCurrencyEffect: + investedCapitalValuesWithCurrencyEffect, + averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance + }; } protected getPerformanceCalculationType() { return PerformanceCalculationType.ROI; } + + private hasDividendWithoutInvestedCapital( + holdings: PortfolioSnapshotHolding[] + ) { + // A holding with a dividend only (without a buy activity), and a holding + // without a market price, gives a dividend but no invested capital. Such a + // holding makes the dividend percentage too high. Therefore the dividend + // percentage stays 0 in this case, like the dividend yield. + return holdings.some( + ({ averageInvestmentWithCurrencyEffect, dividendInBaseCurrency }) => { + return ( + !dividendInBaseCurrency.eq(0) && + averageInvestmentWithCurrencyEffect.eq(0) + ); + } + ); + } } From 6b186778c34b71d2761e8861859f6d2189e70b5c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:26:32 +0200 Subject: [PATCH 2/6] Load activities of ROI tests from export files --- ...tfolio-calculator-baln-buy-and-buy.spec.ts | 43 +-------- ...folio-calculator-baln-buy-and-sell.spec.ts | 43 +-------- .../roi/portfolio-calculator-baln-buy.spec.ts | 94 +++---------------- ...ator-btcusd-buy-and-sell-partially.spec.ts | 43 +-------- ...-calculator-msft-buy-with-dividend.spec.ts | 43 +-------- 5 files changed, 35 insertions(+), 231 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts index 776d9f077f..a7167a2de1 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,45 +83,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy and buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-buy.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts index 0e1099da87..02300923dd 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-sell.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts index 54d339f544..c590f4503f 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,29 +83,13 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -217,29 +199,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -257,29 +224,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -297,29 +249,13 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BALN.SW buy (on a date without historical market data)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 564d264795..0ae6995633 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -15,7 +14,6 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -98,45 +96,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with BTCUSD buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2015-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 320.43 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2017-12-31'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'SELL', - unitPriceInAssetProfileCurrency: 14156.4 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts index 8496f095c9..0c0e32d931 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator (ROI)', () => { it.only('with MSFT buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-09-16'), - feeInAssetProfileCurrency: 19, - feeInBaseCurrency: 19, - quantity: 1, - type: 'BUY', - unitPriceInAssetProfileCurrency: 298.58 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-11-16'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'DIVIDEND', - unitPriceInAssetProfileCurrency: 0.62 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'msft-buy-with-dividend.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROI, - currency: 'USD', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); From 062121eb1c39ce200ddfde5e82713456e709b3d4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:35:31 +0200 Subject: [PATCH 3/6] Implement ROI portfolio calculator --- .../portfolio-summary/portfolio-summary.component.html | 10 ++++++++++ .../user-account-settings/user-account-settings.html | 4 +++- .../app/pages/portfolio/analysis/analysis-page.html | 10 ++++++++++ libs/common/src/lib/dtos/update-user-setting.dto.ts | 5 +++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 3f84d81bf6..314c083b3a 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -159,6 +159,16 @@ title="Return on Average Investment (total return with dividends)" >(ROAI) + } @else if ( + user?.settings?.performanceCalculationType === + PerformanceCalculationType.ROI + ) { + (ROI) }
diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 2d4c090dd2..1889bd88ea 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -39,7 +39,6 @@ Return on Average Investment (ROAI) + Return on Investment (ROI)
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 96dfe24c45..3ca335774d 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -264,6 +264,16 @@ title="Return on Average Investment (total return with dividends)" >(ROAI) + } @else if ( + user?.settings?.performanceCalculationType === + PerformanceCalculationType.ROI + ) { + (ROI) }
diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index d46982e707..87c1f9a054 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -5,6 +5,7 @@ import type { HoldingsViewMode, ViewMode } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { @@ -96,6 +97,10 @@ export class UpdateUserSettingDto { @IsOptional() locale?: string; + @IsIn([PerformanceCalculationType.ROAI, PerformanceCalculationType.ROI]) + @IsOptional() + performanceCalculationType?: PerformanceCalculationType; + /** * The target financial amount the user aims to reach before retiring. * Can be explicitly set to null to clear the value and calculate it dynamically. From c67e389928b55cb353815bdc882a9e3ccb2997f5 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:36:27 +0200 Subject: [PATCH 4/6] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a08d82688..25b268a78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 3.81.0 - 2026-10-07 +### Added + +- Added the _Return on Investment_ (ROI) performance calculation type (experimental) + ### Changed - Moved the performance calculation including dividends (total return) from experimental to general availability From 94c0d150d614e6a3ae0be1f55a3c461236f55986 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:39:17 +0200 Subject: [PATCH 5/6] Implement ROI portfolio calculator --- .../portfolio-summary.component.html | 37 +++++++++---------- .../portfolio/analysis/analysis-page.html | 37 +++++++++---------- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 314c083b3a..79e2289199 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -149,26 +149,23 @@
Net Performance - @if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROAI - ) { - (ROAI) - } @else if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROI - ) { - (ROI) + @switch (user?.settings?.performanceCalculationType) { + @case (PerformanceCalculationType.ROAI) { + (ROAI) + } + @case (PerformanceCalculationType.ROI) { + (ROI) + } }
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 3ca335774d..90b0920053 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -254,26 +254,23 @@
Net Performance - @if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROAI - ) { - (ROAI) - } @else if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROI - ) { - (ROI) + @switch (user?.settings?.performanceCalculationType) { + @case (PerformanceCalculationType.ROAI) { + (ROAI) + } + @case (PerformanceCalculationType.ROI) { + (ROI) + } }
From 53fb736c33417fc43997d95647835e85ce5f5fc6 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:12:19 +0200 Subject: [PATCH 6/6] Align ROI portfolio calculator with total return in general availability --- CHANGELOG.md | 4 +- .../calculator/roi/portfolio-calculator.ts | 56 ++++++------------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b268a78c..987a872351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 3.81.0 - 2026-10-07 +## Unreleased ### Added - Added the _Return on Investment_ (ROI) performance calculation type (experimental) +## 3.81.0 - 2026-10-07 + ### Changed - Moved the performance calculation including dividends (total return) from experimental to general availability 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 8400c0167f..11583490e9 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -17,10 +17,7 @@ import { AssetProfileIdentifier, HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { - PortfolioSnapshot, - PortfolioSnapshotHolding -} from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -116,13 +113,21 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { ? differenceInDays(new Date(), dateOfFirstActivity) : 0; - // Take the dividend from the same source as the portfolio summary, so - // that the response shows one dividend only const totalDividendInBaseCurrency = this.getDividendInBaseCurrencyOfHoldings(positions); - const hasDividendWithoutInvestedCapital = - this.hasDividendWithoutInvestedCapital(positions); + // A holding without a market price, and a holding which is excluded from + // the performance, gives a dividend but no invested capital. Such a + // holding makes the dividend yield too high. Therefore the dividend yield + // stays 0 in this case. + const hasDividendWithoutInvestedCapital = positions.some( + ({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => { + return ( + !dividendInBaseCurrency.eq(0) && + (!includeInPerformance || averageInvestment.eq(0)) + ); + } + ); const dividendYieldPercent = getAnnualizedPerformancePercent({ daysInMarket, @@ -166,15 +171,10 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentages({ - accumulatedValuesByDate, - holdings + accumulatedValuesByDate }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { - const hasDividendWithoutInvestedCapital = - this.hasDividendWithoutInvestedCapital(holdings); - const performancePercentagesByDate: { [date: string]: PerformancePercentages; } = {}; @@ -192,7 +192,6 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { ] of Object.entries(accumulatedValuesByDate)) { performancePercentagesByDate[date] = { dividendInPercentageWithCurrencyEffect: - hasDividendWithoutInvestedCapital || totalInvestedCapitalWithCurrencyEffect.eq(0) ? 0 : totalDividendValueWithCurrencyEffect @@ -214,15 +213,10 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentagesForDateRange({ - historicalDataItems, - holdings + historicalDataItems }: { historicalDataItems: HistoricalDataItem[]; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { - const hasDividendWithoutInvestedCapital = - this.hasDividendWithoutInvestedCapital(holdings); - let investedCapital = 0; let investedCapitalWithCurrencyEffect = 0; @@ -257,7 +251,6 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { performancePercentagesByDate[historicalDataItem.date] = { dividendInPercentageWithCurrencyEffect: - !hasDividendWithoutInvestedCapital && investedCapitalWithCurrencyEffect > 0 ? historicalDataItem.dividendInBaseCurrency / investedCapitalWithCurrencyEffect @@ -376,6 +369,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { const { currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, initialValue, investmentValuesAccumulated, investmentValuesAccumulatedWithCurrencyEffect, @@ -557,6 +551,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { return { currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, dividendYieldPercent, dividendYieldPercentWithCurrencyEffect, grossPerformancePercentage, @@ -591,21 +586,4 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { protected getPerformanceCalculationType() { return PerformanceCalculationType.ROI; } - - private hasDividendWithoutInvestedCapital( - holdings: PortfolioSnapshotHolding[] - ) { - // A holding with a dividend only (without a buy activity), and a holding - // without a market price, gives a dividend but no invested capital. Such a - // holding makes the dividend percentage too high. Therefore the dividend - // percentage stays 0 in this case, like the dividend yield. - return holdings.some( - ({ averageInvestmentWithCurrencyEffect, dividendInBaseCurrency }) => { - return ( - !dividendInBaseCurrency.eq(0) && - averageInvestmentWithCurrencyEffect.eq(0) - ); - } - ); - } }