diff --git a/CHANGELOG.md b/CHANGELOG.md index ef792cf2d4..a3f80d2f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the _Return on Investment_ (ROI) performance calculation type (experimental) - Added a tool to update the cash balance of an account to the server of the Model Context Protocol (MCP) (experimental) ### Changed diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 1efac05e83..43e84ce656 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -94,7 +94,7 @@ export class GhostfolioMcpController { ', ' )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getPerformanceTableValueColumnNames().join( ', ' - )}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, + )}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance uses the performance calculation type of the user (default: return on average investment, ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, name: 'get-performance', parameters: GET_PERFORMANCE_PARAMETERS }) 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-baln-sell-and-buy-on-same-day.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts new file mode 100644 index 0000000000..57e83b486d --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts @@ -0,0 +1,237 @@ +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 sell and buy on the same day', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + // The second holding is BALN.SW of another data source, so that both + // holdings have the same market prices + 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 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'EOD_HISTORICAL_DATA', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + 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'] + }); + + /** + * The invested capital of each holding is the sum of its buy amounts: + * 2 * 136.6 CHF = 273.2 CHF and 2 * 142.9 CHF = 285.8 CHF + */ + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + positions: [ + { + averageInvestment: new Big('273.2'), + averageInvestmentWithCurrencyEffect: new Big('273.2'), + dataSource: 'EOD_HISTORICAL_DATA', + investment: new Big('273.2'), + netPerformance: new Big('24.6'), + netPerformancePercentage: new Big('0.09004392386530014641'), + quantity: new Big('2'), + symbol: 'BALN.SW', + valueInBaseCurrency: new Big('297.8') + }, + { + averageInvestment: new Big('285.8'), + averageInvestmentWithCurrencyEffect: new Big('285.8'), + dataSource: 'YAHOO', + investment: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + quantity: new Big('0'), + symbol: 'BALN.SW', + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInvestment: new Big('273.2'), + totalInvestmentWithCurrencyEffect: new Big('273.2') + }); + + /** + * The sell and the buy on 2021-11-30 move the money from one holding to + * the other. Thus the invested capital of the portfolio stays 285.8 CHF + * and is not 285.8 CHF + 273.2 CHF = 559 CHF. + * + * Net performance: -15.8 CHF + 24.6 CHF = 8.8 CHF + * Net performance in percentage: 8.8 CHF / 285.8 CHF ≈ 3.08 % + */ + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 8.8, + netPerformanceInPercentage: 0.03079076277116865, + netPerformanceInPercentageWithCurrencyEffect: 0.03079076277116865, + netPerformanceWithCurrencyEffect: 8.8, + totalInvestment: 273.2, + totalInvestmentValueWithCurrencyEffect: 273.2 + }) + ); + + /** + * 1d: 2 * (148.9 CHF - 143.9 CHF) = 10 CHF / 287.8 CHF ≈ 3.47 % + * max: the same invested capital as the chart above + */ + expect(performanceByDateRange).toMatchObject({ + '1d': { + date: '2021-12-18', + netPerformance: 10, + netPerformanceInPercentage: 0.03474635163307853, + netPerformanceInPercentageWithCurrencyEffect: 0.03474635163307853, + netPerformanceWithCurrencyEffect: 10, + valueWithCurrencyEffect: 297.8 + }, + max: { + date: '2021-12-18', + netPerformance: 8.8, + netPerformanceInPercentage: 0.03079076277116865, + netPerformanceInPercentageWithCurrencyEffect: 0.03079076277116865, + netPerformanceWithCurrencyEffect: 8.8, + valueWithCurrencyEffect: 297.8 + } + }); + }); + }); +}); 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..3d6a9e35ad --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -0,0 +1,366 @@ +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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +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', '2017', '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'), + '2017': new Big('43.08868315035799522673'), + max: new Big('41.62112555619955206456'), + ytd: new Big('-0.04016229506406263535') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('-556.443324'), + '2017': new Big('27081.23736'), + 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 + }, + '2017': { + date: '2017-12-31', + netPerformance: 26957.033439, + netPerformanceInPercentage: 44.04821002386635, + netPerformanceInPercentageWithCurrencyEffect: 43.088683150357994, + netPerformanceWithCurrencyEffect: 27081.23736, + totalInvestmentValueWithCurrencyEffect: 318.54266729999995, + valueWithCurrencyEffect: 13854.86868 + }, + 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 + } + }); + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: '2017' + }); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + expect(chart[0].date).toBe('2016-12-31'); + }); + + it.only('with BTCUSD buy and sell partially and fewer chart items', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); + + const environmentConfigurationService = new ConfigurationService(); + + // Fewer chart items give fewer chart dates, which must not change the + // invested capital. The range 2017 starts with a gross performance and + // ends with the sell. + jest.spyOn(configurationService, 'get').mockImplementation((key) => { + return key === 'MAX_CHART_ITEMS' + ? 50 + : environmentConfigurationService.get(key); + }); + + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: userCurrency, + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['2017', 'max'] + }); + + expect( + portfolioSnapshot.positions[0] + .netPerformancePercentageWithCurrencyEffectMap + ).toMatchObject({ + '2017': new Big('43.08868315035799522673'), + max: new Big('41.62112555619955206456') + }); + + // Other chart dates give another rounding of the floating point numbers + expect( + performanceByDateRange['2017'].netPerformanceInPercentage + ).toBeCloseTo(44.04821002386635, 10); + + expect( + performanceByDateRange['2017'] + .netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(43.088683150357994, 10); + + expect(performanceByDateRange.max.netPerformanceInPercentage).toBeCloseTo( + 42.400430671285456, + 10 + ); + + expect( + performanceByDateRange.max.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(41.62112555619955, 10); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts new file mode 100644 index 0000000000..59060483ce --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts @@ -0,0 +1,357 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { PortfolioSnapshotHolding } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { eachDayOfInterval, isSameDay } from 'date-fns'; +import { randomUUID } from 'node:crypto'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +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 accountBalanceService: AccountBalanceService; + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + + accountService = new AccountService( + accountBalanceService, + null, + exchangeRateDataService, + null, + null + ); + + redisCacheService = new RedisCacheService(null, configurationService); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + redisCacheService + ); + + currentRateService = new CurrentRateService( + null, + dataProviderService, + null + ); + + activitiesService = new ActivitiesService( + accountBalanceService, + accountService, + { + getSplitsByUserId: jest.fn().mockResolvedValue([]) + } as unknown as AssetProfileSplitService, + null, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('Cash Performance', () => { + it('should count a buy with cash in a foreign currency only once in the invested capital', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + // The account balance goes back to 0 USD with the buy of MSFT + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + date: parseDate('2023-12-31'), + id: randomUUID(), + value: 1000, + valueInBaseCurrency: 850 + }, + { + accountId, + date: parseDate('2024-12-31'), + id: randomUUID(), + value: 0, + valueInBaseCurrency: 0 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 0, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'USD', + id: accountId, + name: 'USD', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 0 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: parseDate('2024-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 100 + } + ], + count: 1 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + // The cash symbol has no market data. The holding is quoted at 100 USD + // and at 110 USD on the last day. + jest + .spyOn(currentRateService, 'getValues') + .mockImplementation(({ dataGatheringItems, dateQuery }) => { + const values = []; + + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { + for (const { dataSource, symbol } of dataGatheringItems) { + if (symbol === 'MSFT') { + values.push({ + date, + dataSource, + symbol, + marketPrice: isSameDay(parseDate('2025-01-01'), date) + ? 110 + : 100 + }); + } + } + } + + return Promise.resolve({ + values, + dataProviderInfos: [], + errors: [] + }); + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['max'] + }); + + /** + * The invested capital of each holding is the sum of its buy amounts: + * 1000 USD * 0.91 = 910 CHF (850 CHF with currency effect) for the cash + * and 10 * 100 USD * 0.91 = 910 CHF (900 CHF with currency effect) for + * MSFT + */ + expect( + portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'USD'; + }) + ).toMatchObject>({ + averageInvestment: new Big(910), + averageInvestmentWithCurrencyEffect: new Big(850), + grossPerformanceWithCurrencyEffect: new Big(50), + investment: new Big(0), + quantity: new Big(0) + }); + + expect( + portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'MSFT'; + }) + ).toMatchObject>({ + averageInvestment: new Big(910), + averageInvestmentWithCurrencyEffect: new Big(900), + grossPerformanceWithCurrencyEffect: new Big(101), + investment: new Big(910), + quantity: new Big(10) + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(1001), + hasErrors: false, + totalInvestment: new Big(910), + totalInvestmentWithCurrencyEffect: new Big(900) + }); + + /** + * The buy of MSFT with the cash moves the money from one holding to the + * other. Thus the invested capital of the portfolio is not the sum of + * the invested capital of the holdings. + * + * Invested capital: 1000 USD * 0.91 = 910 CHF + * Invested capital with currency effect: 1000 USD * 0.85 = 850 CHF plus + * the currency gain of 50 CHF which goes into the buy = 900 CHF + * Net performance: 10 * (110 USD - 100 USD) * 0.91 = 91 CHF + * Net performance in percentage: 91 CHF / 910 CHF = 10 % + * Net performance with currency effect: 50 CHF + 101 CHF = 151 CHF + * Net performance in percentage with currency effect: + * 151 CHF / 900 CHF ≈ 16.78 % + */ + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + date: '2025-01-01', + netPerformance: 91, + netPerformanceInPercentage: 0.1, + netPerformanceInPercentageWithCurrencyEffect: 0.16777777777777778, + netPerformanceWithCurrencyEffect: 151, + totalInvestment: 910, + totalInvestmentValueWithCurrencyEffect: 900 + }) + ); + + expect(performanceByDateRange.max).toMatchObject({ + date: '2025-01-01', + netPerformance: 91, + netPerformanceInPercentage: 0.1, + netPerformanceInPercentageWithCurrencyEffect: 0.16777777777777778, + netPerformanceWithCurrencyEffect: 151 + }); + }); + }); +}); 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..f5a33d371e 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,332 @@ 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. It fills the + * average investment of the holding performance. The average investment values + * per date stay empty, because the invested capital of the portfolio is not the + * sum of the holdings. + * + * The invested capital of the portfolio is the sum of the positive net + * investment changes from date to date. Thus money which moves from one holding + * to another on the same date counts one time only, for example a buy with cash + * in a foreign currency. The invested capital of a date range is the value at + * the start of the range plus the positive net investment changes during the + * range. + * + * A sell amount does not reduce the invested capital, so that a sold holding + * keeps its share of the gain. Therefore money which moves from one holding to + * another on different dates counts two times. + */ 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 } { + let investedCapital = new Big(0); + let investedCapitalWithCurrencyEffect = new Big(0); + let previousTotalInvestmentValue = new Big(0); + let previousTotalInvestmentValueWithCurrencyEffect = new Big(0); + + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + // The dates are in chronological order + for (const [ + date, + { + totalDividendValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + } + ] of Object.entries(accumulatedValuesByDate)) { + // A positive net investment change of the portfolio increases the + // invested capital. A negative change does not reduce it, so that a sold + // holding keeps its share of the gain. + const investmentChange = totalInvestmentValue.minus( + previousTotalInvestmentValue + ); + + if (investmentChange.gt(0)) { + investedCapital = investedCapital.plus(investmentChange); + } + + const investmentChangeWithCurrencyEffect = + totalInvestmentValueWithCurrencyEffect.minus( + previousTotalInvestmentValueWithCurrencyEffect + ); + + if (investmentChangeWithCurrencyEffect.gt(0)) { + investedCapitalWithCurrencyEffect = + investedCapitalWithCurrencyEffect.plus( + investmentChangeWithCurrencyEffect + ); + } + + previousTotalInvestmentValue = totalInvestmentValue; + + previousTotalInvestmentValueWithCurrencyEffect = + totalInvestmentValueWithCurrencyEffect; + + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalDividendValueWithCurrencyEffect + .div(investedCapitalWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: investedCapital.eq(0) + ? 0 + : totalNetPerformanceValue.div(investedCapital).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(investedCapitalWithCurrencyEffect) + .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 { + // A positive net investment change of the portfolio increases the + // invested capital. A negative change does 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; + } + + const investmentChangeWithCurrencyEffect = + historicalDataItem.totalInvestmentValueWithCurrencyEffect - + historicalDataItems[index - 1].totalInvestmentValueWithCurrencyEffect; + + if (investmentChangeWithCurrencyEffect > 0) { + investedCapitalWithCurrencyEffect += + investmentChangeWithCurrencyEffect; + } + } + + 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 +335,291 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { - throw new Error('Method not implemented.'); + 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 + ); + } + } + + 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: {}, + averageInvestmentValuesWithCurrencyEffect: {}, + averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance + }; } protected getPerformanceCalculationType() { diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 60f184ed0f..ba0adc0cdc 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -244,6 +244,13 @@ export class UserController { delete userSettings.isGhostfolioAiEnabled; } + if ( + !userSettings.isExperimentalFeatures || + this.request.user.subscription + ) { + delete userSettings.performanceCalculationType; + } + return this.userService.updateUserSetting({ emitPortfolioChangedEvent, userSettings, 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 d40249cafb..f608e1e7be 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 055276129a..321a04e526 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,9 @@ 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 bd8451cd38..08dad5343c 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.