diff --git a/CHANGELOG.md b/CHANGELOG.md index 8182f9d527..66c8b8ac37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## Unreleased + +### Added + +- Added the _Return on Investment_ (ROI) performance calculation type (experimental) + ## 3.85.0 - 2026-10-12 ### Added 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..a7167a2de1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -0,0 +1,185 @@ +import { + loadActivitiesFromExportFile, + 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 { 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, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-buy.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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..02300923dd --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -0,0 +1,222 @@ +import { + getPerformanceByDateRange, + loadActivitiesFromExportFile, + 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 { 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, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-sell.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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..c590f4503f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts @@ -0,0 +1,276 @@ +import { + loadActivitiesFromExportFile, + 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 { 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, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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..0ae6995633 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -0,0 +1,282 @@ +import { + getPerformanceByDateRange, + loadActivitiesFromExportFile, + 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 { 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, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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..0c0e32d931 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,202 @@ +import { + getPerformanceByDateRange, + loadActivitiesFromExportFile, + 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 { 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, userCurrency } = loadActivitiesFromExportFile( + 'msft-buy-with-dividend.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + 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..11583490e9 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,284 @@ 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 { + 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 } 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; + + const totalDividendInBaseCurrency = + this.getDividendInBaseCurrencyOfHoldings(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, + 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 + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }): { [date: string]: PerformancePercentages } { + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + for (const [ + date, + { + totalAverageInvestmentValue: totalInvestedCapital, + totalAverageInvestmentValueWithCurrencyEffect: + totalInvestedCapitalWithCurrencyEffect, + totalDividendValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + } + ] of Object.entries(accumulatedValuesByDate)) { + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + 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 + }: { + historicalDataItems: HistoricalDataItem[]; + }): { [date: string]: PerformancePercentages } { + 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: + 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,7 +287,300 @@ 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, + dividendValuesWithCurrencyEffect, + 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, + dividendValuesWithCurrencyEffect, + 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() { 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..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,16 +149,23 @@
Net Performance - @if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROAI - ) { - (ROAI) + @switch (user?.settings?.performanceCalculationType) { + @case (PerformanceCalculationType.ROAI) { + (ROAI) + } + @case (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 6886baa46a..1d317dd8c3 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 6f05dae069..10213a8a17 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -254,16 +254,23 @@
Net Performance - @if ( - user?.settings?.performanceCalculationType === - PerformanceCalculationType.ROAI - ) { - (ROAI) + @switch (user?.settings?.performanceCalculationType) { + @case (PerformanceCalculationType.ROAI) { + (ROAI) + } + @case (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 d290f1fdad..b51d8bb61b 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 { @@ -100,6 +101,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.