From c6627fc563b0866fea6bdb35602671651c6e2c7c Mon Sep 17 00:00:00 2001 From: Dan Date: Sun, 27 Apr 2025 10:48:11 +0200 Subject: [PATCH 01/16] Use total investment as basis to avoid performance lower than -100% --- .../calculator/roai/portfolio-calculator.ts | 78 ++++--------------- 1 file changed, 17 insertions(+), 61 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 4fa07e1ac..dbdf87335 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -767,19 +767,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ) : new Big(0); - const grossPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); + const grossPerformancePercentage = totalInvestment.gt(0) + ? totalGrossPerformance.div(totalInvestment) + : new Big(0); const grossPerformancePercentageWithCurrencyEffect = - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( - 0 - ) + totalInvestmentWithCurrencyEffect.gt(0) ? totalGrossPerformanceWithCurrencyEffect.div( - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + totalInvestmentWithCurrencyEffect ) : new Big(0); @@ -793,12 +788,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { .div(totalUnits) : new Big(0); - const netPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); + const netPerformancePercentage = totalInvestment.gt(0) + ? totalNetPerformance.div(totalInvestment) + : new Big(0); const netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big; @@ -810,6 +802,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { for (const dateRange of [ '1d', + '1w', + '1m', + '3m', '1y', '5y', 'max', @@ -836,48 +831,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const rangeEndDateString = format(endDate, DATE_FORMAT); const rangeStartDateString = format(startDate, DATE_FORMAT); - const currentValuesAtDateRangeStartWithCurrencyEffect = - currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); - - const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = - investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? - new Big(0); - - const grossPerformanceAtDateRangeStartWithCurrencyEffect = - currentValuesAtDateRangeStartWithCurrencyEffect.minus( - investmentValuesAccumulatedAtStartDateWithCurrencyEffect - ); - - let average = new Big(0); - let dayCount = 0; - - for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { - const date = this.chartDates[i]; - - if (date > rangeEndDateString) { - continue; - } else if (date < rangeStartDateString) { - break; - } - - if ( - investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && - investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) - ) { - average = average.add( - investmentValuesAccumulatedWithCurrencyEffect[date].add( - grossPerformanceAtDateRangeStartWithCurrencyEffect - ) - ); - - dayCount++; - } - } - - if (dayCount > 0) { - average = average.div(dayCount); - } - netPerformanceWithCurrencyEffectMap[dateRange] = netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( // If the date range is 'max', take 0 as a start value. Otherwise, @@ -889,9 +842,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { new Big(0)) ) ?? new Big(0); - netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) - ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) - : new Big(0); + netPerformancePercentageWithCurrencyEffectMap[dateRange] = + investmentValuesAccumulatedWithCurrencyEffect[rangeEndDateString]?.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div( + investmentValuesAccumulatedWithCurrencyEffect[rangeEndDateString] + ) + : new Big(0); } if (PortfolioCalculator.ENABLE_LOGGING) { From a3a3f411a36c990ea302b8a07adf3134c258b867 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 May 2025 14:22:12 +0200 Subject: [PATCH 02/16] Implement ROI Calculator --- .../portfolio-calculator-googl-buy.spec.ts | 208 +++++ .../roi/portfolio-calculator-helper-object.ts | 39 + ...-calculator-msft-buy-with-dividend.spec.ts | 198 ++++ ...ulator-novn-buy-and-sell-partially.spec.ts | 202 ++++ ...folio-calculator-novn-buy-and-sell.spec.ts | 253 +++++ ...rtfolio-calculator-symbolmetrics-helper.ts | 861 ++++++++++++++++++ .../calculator/roi/portfolio-calculator.ts | 252 ++++- libs/common/src/lib/types/date-range.type.ts | 13 + 8 files changed, 2020 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts new file mode 100644 index 000000000..7034d86eb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts @@ -0,0 +1,208 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + 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 { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with GOOGL buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 89.12 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('103.10483'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('89.12'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1'), + feeInBaseCurrency: new Big('0.9238'), + firstBuyDate: '2023-01-03', + grossPerformance: new Big('27.33').mul(0.8854), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + investment: new Big('89.12').mul(0.8854), + investmentWithCurrencyEffect: new Big('82.329056'), + netPerformance: new Big('26.33').mul(0.8854), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.24112962014285697628') + }, + netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') }, + marketPrice: 116.45, + marketPriceInBaseCurrency: 103.10483, + quantity: new Big('1'), + symbol: 'GOOGL', + tags: [], + timeWeightedInvestment: new Big('89.12').mul(0.8854), + timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') + } + ], + totalFeesWithCurrencyEffect: new Big('0.9238'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('89.12').mul(0.8854), + totalInvestmentWithCurrencyEffect: new Big('82.329056'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('26.33').mul(0.8854).toNumber(), + netPerformanceInPercentage: 0.29544434470377019749, + netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, + netPerformanceWithCurrencyEffect: 19.851974, + totalInvestmentValueWithCurrencyEffect: 82.329056 + }) + ); + + expect(investments).toEqual([ + { date: '2023-01-03', investment: new Big('89.12') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2023-01-01', investment: 82.329056 }, + { date: '2023-02-01', investment: 0 }, + { date: '2023-03-01', investment: 0 }, + { date: '2023-04-01', investment: 0 }, + { date: '2023-05-01', investment: 0 }, + { date: '2023-06-01', investment: 0 }, + { date: '2023-07-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts new file mode 100644 index 000000000..52d631e46 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts @@ -0,0 +1,39 @@ +import { SymbolMetrics } from '@ghostfolio/common/interfaces'; + +import { Big } from 'big.js'; + +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; + +export class PortfolioCalculatorSymbolMetricsHelperObject { + currentExchangeRate: number; + endDateString: string; + exchangeRateAtOrderDate: number; + fees: Big = new Big(0); + feesWithCurrencyEffect: Big = new Big(0); + feesAtStartDate: Big = new Big(0); + feesAtStartDateWithCurrencyEffect: Big = new Big(0); + grossPerformanceAtStartDate: Big = new Big(0); + grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0); + indexOfEndOrder: number; + indexOfStartOrder: number; + initialValue: Big; + initialValueWithCurrencyEffect: Big; + investmentAtStartDate: Big; + investmentAtStartDateWithCurrencyEffect: Big; + investmentValueBeforeTransaction: Big = new Big(0); + investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0); + ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; + startDateString: string; + symbolMetrics: SymbolMetrics; + totalUnits: Big = new Big(0); + totalInvestmentFromBuyTransactions: Big = new Big(0); + totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0); + totalQuantityFromBuyTransactions: Big = new Big(0); + totalValueOfPositionsSold: Big = new Big(0); + totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0); + unitPrice: Big; + unitPriceAtEndDate: Big = new Big(0); + unitPriceAtStartDate: Big = new Big(0); + valueAtStartDate: Big = new Big(0); + valueAtStartDateWithCurrencyEffect: Big = new Big(0); +} 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 000000000..f57df03cd --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,198 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + 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 { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + fee: new Big('19'), + firstBuyDate: '2021-09-16', + 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: [], + transactionCount: 2 + } + ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + totalInvestmentValueWithCurrencyEffect: 298.58 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts new file mode 100644 index 000000000..a2e10df70 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -0,0 +1,202 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + 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'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell-partially.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('87.8'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('75.80'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.25'), + feeInBaseCurrency: new Big('4.25'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('21.93'), + grossPerformancePercentage: new Big('0.14465699208443271768'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.14465699208443271768' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), + investment: new Big('75.80'), + investmentWithCurrencyEffect: new Big('75.80'), + netPerformance: new Big('17.68'), + netPerformancePercentage: new Big('0.11662269129287598945'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.11662269129287598945') + }, + netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('1'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + valueInBaseCurrency: new Big('87.8') + } + ], + totalFeesWithCurrencyEffect: new Big('4.25'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('75.80'), + totalInvestmentWithCurrencyEffect: new Big('75.80'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 17.68, + netPerformanceInPercentage: 0.11662269129287598945, + netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945, + netPerformanceWithCurrencyEffect: 17.68, + totalInvestmentValueWithCurrencyEffect: 75.8 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('75.8') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -75.8 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts new file mode 100644 index 000000000..304ab4617 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -0,0 +1,253 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + 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'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 151.6, + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 151.6, + valueWithCurrencyEffect: 151.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts new file mode 100644 index 000000000..9177befb1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts @@ -0,0 +1,861 @@ +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { SymbolMetrics } from '@ghostfolio/common/interfaces'; +import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { isBefore, addMilliseconds, format } from 'date-fns'; +import { sortBy } from 'lodash'; + +import { getFactor } from '../../../../helper/portfolio.helper'; +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; +import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object'; + +export class RoiPortfolioCalculatorSymbolMetricsHelper { + private ENABLE_LOGGING: boolean; + private baseCurrencySuffix = 'InBaseCurrency'; + private chartDates: string[]; + private marketSymbolMap: { [date: string]: { [symbol: string]: Big } }; + public constructor( + ENABLE_LOGGING: boolean, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + chartDates: string[] + ) { + this.ENABLE_LOGGING = ENABLE_LOGGING; + this.marketSymbolMap = marketSymbolMap; + this.chartDates = chartDates; + } + + public calculateNetPerformanceByDateRange( + start: Date, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + for (const dateRange of DateRangeTypes) { + 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); + + symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ + dateRange + ] = + symbolMetricsHelper.symbolMetrics.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) + : (symbolMetricsHelper.symbolMetrics + .netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0); + + symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[ + dateRange + ] = + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[ + rangeEndDateString + ]?.gt(0) + ? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ + dateRange + ].div( + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentValuesWithCurrencyEffect[ + rangeEndDateString + ] + ) + : new Big(0); + } + } + + public handleOverallPerformanceCalculation( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + symbolMetricsHelper.symbolMetrics.grossPerformance = + symbolMetricsHelper.symbolMetrics.grossPerformance.minus( + symbolMetricsHelper.grossPerformanceAtStartDate + ); + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus( + symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect + ); + + symbolMetricsHelper.symbolMetrics.netPerformance = + symbolMetricsHelper.symbolMetrics.grossPerformance.minus( + symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) + ); + + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactions + ); + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect = + new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ); + + if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) { + symbolMetricsHelper.symbolMetrics.netPerformancePercentage = + symbolMetricsHelper.symbolMetrics.netPerformance.div( + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment + ); + symbolMetricsHelper.symbolMetrics.grossPerformancePercentage = + symbolMetricsHelper.symbolMetrics.grossPerformance.div( + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment + ); + symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div( + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentWithCurrencyEffect + ); + } + } + + public processOrderMetrics( + orders: PortfolioOrderItem[], + i: number, + exchangeRates: { [dateString: string]: number }, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const order = orders[i]; + this.writeOrderToLogIfNecessary(i, order); + + symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date]; + const value = order.quantity.gt(0) + ? order.quantity.mul(order.unitPrice) + : new Big(0); + + this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper); + this.handleStartOrder( + order, + i, + orders, + symbolMetricsHelper.unitPriceAtStartDate + ); + this.handleOrderFee(order, symbolMetricsHelper); + symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations( + order, + symbolMetricsHelper + ); + + if (order.unitPriceInBaseCurrency) { + symbolMetricsHelper.investmentValueBeforeTransaction = + symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency); + symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect = + symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + } + + this.handleInitialInvestmentValues(symbolMetricsHelper, i, order); + + const { transactionInvestment, transactionInvestmentWithCurrencyEffect } = + this.handleBuyAndSellTranscation(order, symbolMetricsHelper); + + this.logTransactionValuesIfRequested( + order, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.updateTotalInvestments( + symbolMetricsHelper, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.setInitialValueIfNecessary( + symbolMetricsHelper, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.accumulateFees(symbolMetricsHelper, order); + + symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus( + order.quantity.mul(getFactor(order.type)) + ); + + this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper); + + const valueOfInvestment = symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrency + ); + + const valueOfInvestmentWithCurrencyEffect = + symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + + const valueOfPositionsSold = + order.type === 'SELL' + ? order.unitPriceInBaseCurrency.mul(order.quantity) + : new Big(0); + + const valueOfPositionsSoldWithCurrencyEffect = + order.type === 'SELL' + ? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity) + : new Big(0); + + symbolMetricsHelper.totalValueOfPositionsSold = + symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold); + symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect = + symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus( + valueOfPositionsSoldWithCurrencyEffect + ); + + this.handlePerformanceCalculation( + valueOfInvestment, + symbolMetricsHelper, + valueOfInvestmentWithCurrencyEffect, + order + ); + + symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] = + new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()); + + symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[ + order.date + ] = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + + symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ + order.date + ] = ( + symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ + order.date + ] ?? new Big(0) + ).add(transactionInvestmentWithCurrencyEffect); + } + + public handlePerformanceCalculation( + valueOfInvestment: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + valueOfInvestmentWithCurrencyEffect: Big, + order: PortfolioOrderItem + ) { + this.calculateGrossPerformance( + valueOfInvestment, + symbolMetricsHelper, + valueOfInvestmentWithCurrencyEffect + ); + + this.calculateNetPerformance( + symbolMetricsHelper, + order, + valueOfInvestment, + valueOfInvestmentWithCurrencyEffect + ); + } + + public calculateNetPerformance( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem, + valueOfInvestment: Big, + valueOfInvestmentWithCurrencyEffect: Big + ) { + symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big( + valueOfInvestment + ); + symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ + order.date + ] = new Big(valueOfInvestmentWithCurrencyEffect); + + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] = + new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions); + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[ + order.date + ] = new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ); + + symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] = + symbolMetricsHelper.symbolMetrics.grossPerformance + .minus(symbolMetricsHelper.grossPerformanceAtStartDate) + .minus( + symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) + ); + + symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[ + order.date + ] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect + .minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + symbolMetricsHelper.feesWithCurrencyEffect.minus( + symbolMetricsHelper.feesAtStartDateWithCurrencyEffect + ) + ); + } + + public calculateGrossPerformance( + valueOfInvestment: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + valueOfInvestmentWithCurrencyEffect: Big + ) { + const newGrossPerformance = valueOfInvestment + .minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions) + .plus(symbolMetricsHelper.totalValueOfPositionsSold) + .plus( + symbolMetricsHelper.symbolMetrics.totalDividend.mul( + symbolMetricsHelper.currentExchangeRate + ) + ) + .plus( + symbolMetricsHelper.symbolMetrics.totalInterest.mul( + symbolMetricsHelper.currentExchangeRate + ) + ); + + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ) + .plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect) + .plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency) + .plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency); + + symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance; + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + } + + public accumulateFees( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem + ) { + symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus( + order.feeInBaseCurrency ?? 0 + ); + + symbolMetricsHelper.feesWithCurrencyEffect = + symbolMetricsHelper.feesWithCurrencyEffect.plus( + order.feeInBaseCurrencyWithCurrencyEffect ?? 0 + ); + } + + public updateTotalInvestments( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + symbolMetricsHelper.symbolMetrics.totalInvestment = + symbolMetricsHelper.symbolMetrics.totalInvestment.plus( + transactionInvestment + ); + + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } + + public setInitialValueIfNecessary( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) { + symbolMetricsHelper.initialValue = transactionInvestment; + symbolMetricsHelper.initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; + } + } + + public logTransactionValuesIfRequested( + order: PortfolioOrderItem, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + if (this.ENABLE_LOGGING) { + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); + } + } + + public handleBuyAndSellTranscation( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + switch (order.type) { + case 'BUY': + return this.handleBuyTransaction(order, symbolMetricsHelper); + case 'SELL': + return this.handleSellTransaction(symbolMetricsHelper, order); + default: + return { + transactionInvestment: new Big(0), + transactionInvestmentWithCurrencyEffect: new Big(0) + }; + } + } + + public handleSellTransaction( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem + ) { + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + if (symbolMetricsHelper.totalUnits.gt(0)) { + transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment + .div(symbolMetricsHelper.totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect + .div(symbolMetricsHelper.totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; + } + + public handleBuyTransaction( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + + const transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + + symbolMetricsHelper.totalQuantityFromBuyTransactions = + symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity); + + symbolMetricsHelper.totalInvestmentFromBuyTransactions = + symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus( + transactionInvestment + ); + + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect = + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; + } + + public handleInitialInvestmentValues( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + i: number, + order: PortfolioOrderItem + ) { + if ( + !symbolMetricsHelper.investmentAtStartDate && + i >= symbolMetricsHelper.indexOfStartOrder + ) { + symbolMetricsHelper.investmentAtStartDate = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() + ); + symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + + symbolMetricsHelper.valueAtStartDate = new Big( + symbolMetricsHelper.investmentValueBeforeTransaction.toNumber() + ); + + symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big( + symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber() + ); + } + if (order.itemType === 'start') { + symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees; + symbolMetricsHelper.feesAtStartDateWithCurrencyEffect = + symbolMetricsHelper.feesWithCurrencyEffect; + symbolMetricsHelper.grossPerformanceAtStartDate = + symbolMetricsHelper.symbolMetrics.grossPerformance; + + symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect; + } + + if ( + i >= symbolMetricsHelper.indexOfStartOrder && + !symbolMetricsHelper.initialValue + ) { + if ( + i === symbolMetricsHelper.indexOfStartOrder && + !symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0) + ) { + symbolMetricsHelper.initialValue = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() + ); + + symbolMetricsHelper.initialValueWithCurrencyEffect = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + } + } + } + + public getSymbolMetricHelperObject( + exchangeRates: { [dateString: string]: number }, + start: Date, + end: Date, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + symbol: string + ): PortfolioCalculatorSymbolMetricsHelperObject { + const symbolMetricsHelper = + new PortfolioCalculatorSymbolMetricsHelperObject(); + symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics(); + symbolMetricsHelper.currentExchangeRate = + exchangeRates[format(new Date(), DATE_FORMAT)]; + symbolMetricsHelper.startDateString = format(start, DATE_FORMAT); + symbolMetricsHelper.endDateString = format(end, DATE_FORMAT); + symbolMetricsHelper.unitPriceAtStartDate = + marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol]; + symbolMetricsHelper.unitPriceAtEndDate = + marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol]; + + symbolMetricsHelper.totalUnits = new Big(0); + + return symbolMetricsHelper; + } + + public getUnitPriceAndFillCurrencyDeviations( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const unitprice = ['BUY', 'SELL'].includes(order.type) + ? order.unitPrice + : order.unitPriceFromMarketData; + if (unitprice) { + order.unitPriceInBaseCurrency = unitprice.mul( + symbolMetricsHelper.currentExchangeRate ?? 1 + ); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul( + symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 + ); + } + return unitprice; + } + + public handleOrderFee( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + if (order.fee) { + order.feeInBaseCurrency = order.fee.mul( + symbolMetricsHelper.currentExchangeRate ?? 1 + ); + order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( + symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 + ); + } + } + + public handleStartOrder( + order: PortfolioOrderItem, + i: number, + orders: PortfolioOrderItem[], + unitPriceAtStartDate: Big.Big + ) { + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate; + } + } + + public handleNoneBuyAndSellOrders( + order: PortfolioOrderItem, + value: Big.Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type); + if (symbolMetricsKey) { + this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey); + } + } + + public getSymbolMetricsKeyFromOrderType( + orderType: PortfolioOrderItem['type'] + ): keyof SymbolMetrics { + switch (orderType) { + case 'DIVIDEND': + return 'totalDividend'; + case 'INTEREST': + return 'totalInterest'; + case 'ITEM': + return 'totalValuables'; + case 'LIABILITY': + return 'totalLiabilities'; + default: + return undefined; + } + } + + public calculateMetrics( + value: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + key: keyof SymbolMetrics + ) { + const stringKey = key.toString(); + symbolMetricsHelper.symbolMetrics[stringKey] = ( + symbolMetricsHelper.symbolMetrics[stringKey] as Big + ).plus(value); + + if ( + Object.keys(symbolMetricsHelper.symbolMetrics).includes( + stringKey + this.baseCurrencySuffix + ) + ) { + symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = ( + symbolMetricsHelper.symbolMetrics[ + stringKey + this.baseCurrencySuffix + ] as Big + ).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1)); + } else { + throw new Error( + `Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics` + ); + } + } + + public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) { + if (this.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log( + i + 1, + order.date, + order.type, + order.itemType ? `(${order.itemType})` : '' + ); + } + } + + public fillOrdersAndSortByTime( + orders: PortfolioOrderItem[], + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + chartDateMap: { [date: string]: boolean }, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + symbol: string, + dataSource: DataSource + ) { + this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate); + + this.chartDates ??= Object.keys(chartDateMap).sort(); + + this.fillOrdersWithDatesFromChartDate( + symbolMetricsHelper, + marketSymbolMap, + symbol, + orders, + dataSource + ); + + // Sort orders so that the start and end placeholder order are at the correct + // position + orders = this.sortOrdersByTime(orders); + return orders; + } + + public sortOrdersByTime(orders: PortfolioOrderItem[]) { + orders = sortBy(orders, ({ date, itemType }) => { + let sortIndex = new Date(date); + + if (itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + return sortIndex.getTime(); + }); + return orders; + } + + public fillOrdersWithDatesFromChartDate( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + symbol: string, + orders: PortfolioOrderItem[], + dataSource: DataSource + ) { + let lastUnitPrice: Big; + for (const dateString of this.chartDates) { + if (dateString < symbolMetricsHelper.startDateString) { + continue; + } else if (dateString > symbolMetricsHelper.endDateString) { + break; + } + + if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) { + for (const order of symbolMetricsHelper.ordersByDate[dateString]) { + order.unitPriceFromMarketData = + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; + } + } else { + orders.push( + this.getFakeOrder( + dateString, + dataSource, + symbol, + marketSymbolMap, + lastUnitPrice + ) + ); + } + + const lastOrder = orders.at(-1); + + lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; + } + return lastUnitPrice; + } + + public getFakeOrder( + dateString: string, + dataSource: DataSource, + symbol: string, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + lastUnitPrice: Big.Big + ): PortfolioOrderItem { + return { + date: dateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, + unitPriceFromMarketData: + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice + }; + } + + public fillOrdersByDate( + orders: PortfolioOrderItem[], + ordersByDate: { [date: string]: PortfolioOrderItem[] } + ) { + for (const order of orders) { + ordersByDate[order.date] = ordersByDate[order.date] ?? []; + ordersByDate[order.date].push(order); + } + } + + public addSyntheticStartAndEndOrder( + orders: PortfolioOrderItem[], + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + dataSource: DataSource, + symbol: string + ) { + orders.push({ + date: symbolMetricsHelper.startDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: symbolMetricsHelper.unitPriceAtStartDate + }); + + orders.push({ + date: symbolMetricsHelper.endDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + SymbolProfile: { + dataSource, + symbol + }, + quantity: new Big(0), + type: 'BUY', + unitPrice: symbolMetricsHelper.unitPriceAtEndDate + }); + } + + public hasNoUnitPriceAtEndOrStartDate( + unitPriceAtEndDate: Big.Big, + unitPriceAtStartDate: Big.Big, + orders: PortfolioOrderItem[], + start: Date + ) { + return ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start)) + ); + } + + public createEmptySymbolMetrics(): SymbolMetrics { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffectMap: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + unitPrices: {}, + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + private fillOrderUnitPricesIfMissing( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[ + order.SymbolProfile.symbol + ].mul(symbolMetricsHelper.currentExchangeRate); + + order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[ + order.date + ]?.[order.SymbolProfile.symbol].mul( + symbolMetricsHelper.exchangeRateAtOrderDate + ); + } +} 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 b4929c570..150bacdda 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -3,27 +3,267 @@ import { AssetProfileIdentifier, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { cloneDeep } from 'lodash'; + +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; +import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper'; + export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance(): PortfolioSnapshot { - throw new Error('Method not implemented.'); + private chartDates: string[]; + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let hasErrors = false; + let netPerformance = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + const totalInterestWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions) { + ({ + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + } = this.calculatePositionMetrics( + currentPosition, + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + )); + } + + return { + currentValueInBaseCurrency, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL', 'STAKE'].includes(type); + }).length, + createdAt: new Date(), + errors: [], + historicalData: [], + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; } protected getPerformanceCalculationType() { return PerformanceCalculationType.ROI; } - protected getSymbolMetrics({}: { + protected getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap?: { [date: string]: boolean }; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { [date: string]: { [symbol: string]: Big }; }; start: Date; - step?: number; } & AssetProfileIdentifier): SymbolMetrics { - throw new Error('Method not implemented.'); + if (!this.chartDates) { + this.chartDates = Object.keys(chartDateMap).sort(); + } + const symbolMetricsHelperClass = + new RoiPortfolioCalculatorSymbolMetricsHelper( + PortfolioCalculator.ENABLE_LOGGING, + marketSymbolMap, + this.chartDates + ); + const symbolMetricsHelper = + symbolMetricsHelperClass.getSymbolMetricHelperObject( + exchangeRates, + start, + end, + marketSymbolMap, + symbol + ); + + let orders: PortfolioOrderItem[] = cloneDeep( + this.activities.filter(({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + }) + ); + + if (!orders.length) { + return symbolMetricsHelper.symbolMetrics; + } + + if ( + symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate( + symbolMetricsHelper.unitPriceAtEndDate, + symbolMetricsHelper.unitPriceAtStartDate, + orders, + start + ) + ) { + symbolMetricsHelper.symbolMetrics.hasErrors = true; + return symbolMetricsHelper.symbolMetrics; + } + + symbolMetricsHelperClass.addSyntheticStartAndEndOrder( + orders, + symbolMetricsHelper, + dataSource, + symbol + ); + + orders = symbolMetricsHelperClass.fillOrdersAndSortByTime( + orders, + symbolMetricsHelper, + chartDateMap, + marketSymbolMap, + symbol, + dataSource + ); + + symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + for (let i = 0; i < orders.length; i++) { + symbolMetricsHelperClass.processOrderMetrics( + orders, + i, + exchangeRates, + symbolMetricsHelper + ); + if (i === symbolMetricsHelper.indexOfEndOrder) { + break; + } + } + + symbolMetricsHelperClass.handleOverallPerformanceCalculation( + symbolMetricsHelper + ); + symbolMetricsHelperClass.calculateNetPerformanceByDateRange( + start, + symbolMetricsHelper + ); + + return symbolMetricsHelper.symbolMetrics; + } + + private calculatePositionMetrics( + currentPosition: TimelinePosition, + totalFeesWithCurrencyEffect: Big, + currentValueInBaseCurrency: Big, + hasErrors: boolean, + totalInvestment: Big, + totalInvestmentWithCurrencyEffect: Big, + grossPerformance: Big, + grossPerformanceWithCurrencyEffect: Big, + netPerformance: Big, + totalTimeWeightedInvestment: Big, + totalTimeWeightedInvestmentWithCurrencyEffect: Big + ) { + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + + netPerformance = netPerformance.plus(currentPosition.netPerformance); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if (currentPosition.timeWeightedInvestment) { + totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( + currentPosition.timeWeightedInvestment + ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, + 'PortfolioCalculator' + ); + + hasErrors = true; + } + return { + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + }; } } diff --git a/libs/common/src/lib/types/date-range.type.ts b/libs/common/src/lib/types/date-range.type.ts index 7d823e630..80bafdc6d 100644 --- a/libs/common/src/lib/types/date-range.type.ts +++ b/libs/common/src/lib/types/date-range.type.ts @@ -10,3 +10,16 @@ export type DateRange = | '5y' | 'max' | string; // '2024', '2023', '2022', etc. + +export const DateRangeTypes: DateRange[] = [ + '1d', + 'wtd', + '1w', + 'mtd', + '1m', + '3m', + 'ytd', + '1y', + '5y', + 'max' +]; From de5d378e001d5d1d8f266c80cf1309fadaf473dc Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 May 2025 14:32:39 +0200 Subject: [PATCH 03/16] Revert "Use total investment as basis to avoid performance lower than -100%" This reverts commit c6627fc563b0866fea6bdb35602671651c6e2c7c. --- .../calculator/roai/portfolio-calculator.ts | 78 +++++++++++++++---- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index dbdf87335..4fa07e1ac 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -767,14 +767,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ) : new Big(0); - const grossPerformancePercentage = totalInvestment.gt(0) - ? totalGrossPerformance.div(totalInvestment) - : new Big(0); + const grossPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); const grossPerformancePercentageWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.gt(0) + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) ? totalGrossPerformanceWithCurrencyEffect.div( - totalInvestmentWithCurrencyEffect + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect ) : new Big(0); @@ -788,9 +793,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { .div(totalUnits) : new Big(0); - const netPerformancePercentage = totalInvestment.gt(0) - ? totalNetPerformance.div(totalInvestment) - : new Big(0); + const netPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); const netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big; @@ -802,9 +810,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { for (const dateRange of [ '1d', - '1w', - '1m', - '3m', '1y', '5y', 'max', @@ -831,6 +836,48 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const rangeEndDateString = format(endDate, DATE_FORMAT); const rangeStartDateString = format(startDate, DATE_FORMAT); + const currentValuesAtDateRangeStartWithCurrencyEffect = + currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); + + const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = + investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? + new Big(0); + + const grossPerformanceAtDateRangeStartWithCurrencyEffect = + currentValuesAtDateRangeStartWithCurrencyEffect.minus( + investmentValuesAccumulatedAtStartDateWithCurrencyEffect + ); + + let average = new Big(0); + let dayCount = 0; + + for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { + const date = this.chartDates[i]; + + if (date > rangeEndDateString) { + continue; + } else if (date < rangeStartDateString) { + break; + } + + if ( + investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && + investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) + ) { + average = average.add( + investmentValuesAccumulatedWithCurrencyEffect[date].add( + grossPerformanceAtDateRangeStartWithCurrencyEffect + ) + ); + + dayCount++; + } + } + + if (dayCount > 0) { + average = average.div(dayCount); + } + netPerformanceWithCurrencyEffectMap[dateRange] = netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( // If the date range is 'max', take 0 as a start value. Otherwise, @@ -842,12 +889,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { new Big(0)) ) ?? new Big(0); - netPerformancePercentageWithCurrencyEffectMap[dateRange] = - investmentValuesAccumulatedWithCurrencyEffect[rangeEndDateString]?.gt(0) - ? netPerformanceWithCurrencyEffectMap[dateRange].div( - investmentValuesAccumulatedWithCurrencyEffect[rangeEndDateString] - ) - : new Big(0); + netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) + : new Big(0); } if (PortfolioCalculator.ENABLE_LOGGING) { From 997fbb7b01a1c40a78059c65cdc8effeffd28b02 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 May 2025 14:40:48 +0200 Subject: [PATCH 04/16] Generalize get performance method --- .../calculator/portfolio-calculator.ts | 27 +++++++++++++++++-- .../src/app/portfolio/portfolio.service.ts | 13 ++------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 629c4b33e..9cfbefd08 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -774,7 +774,16 @@ export abstract class PortfolioCalculator { } @LogPerformance - public async getPerformance({ end, start }) { + public async getPerformance({ end, start }): Promise<{ + chart: HistoricalDataItem[]; + netPerformance: number; + netPerformanceInPercentage: number; + netPerformanceWithCurrencyEffect: number; + netPerformanceInPercentageWithCurrencyEffect: number; + netWorth: number; + totalInvestment: number; + valueWithCurrencyEffect: number; + }> { await this.snapshotPromise; const { historicalData } = this.snapshot; @@ -815,6 +824,7 @@ export abstract class PortfolioCalculator { totalInvestmentValuesWithCurrencyEffect.length : 0; + //TODO : Extract in abstractFunction and use timeweighted for ROI + Handle total values separately chart.push({ ...historicalDataItem, netPerformance: @@ -839,7 +849,20 @@ export abstract class PortfolioCalculator { } } - return { chart }; + const last = chart.at(-1); + + return { + chart, + netPerformance: last?.netPerformance ?? 0, + netPerformanceInPercentage: last?.netPerformanceInPercentage ?? 0, + netPerformanceWithCurrencyEffect: + last?.netPerformanceWithCurrencyEffect ?? 0, + netPerformanceInPercentageWithCurrencyEffect: + last?.netPerformanceInPercentageWithCurrencyEffect ?? 0, + netWorth: last?.netWorth ?? 0, + totalInvestment: last?.totalInvestment ?? 0, + valueWithCurrencyEffect: last?.valueWithCurrencyEffect ?? 0 + }; } public getStartDate() { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 2fab5e5ec..96c89fc63 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1155,9 +1155,8 @@ export class PortfolioService { const { endDate, startDate } = getIntervalFromDateRange(dateRange); const range = { end: endDate, start: startDate }; - const { chart } = await portfolioCalculator.getPerformance(range); - const { + chart, netPerformance, netPerformanceInPercentage, netPerformanceInPercentageWithCurrencyEffect, @@ -1165,15 +1164,7 @@ export class PortfolioService { netWorth, totalInvestment, valueWithCurrencyEffect - } = chart?.at(-1) ?? { - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalInvestment: 0, - valueWithCurrencyEffect: 0 - }; + } = await portfolioCalculator.getPerformance(range); return { chart, From 1e0e318c17c1d24fd657f8dd58ca152c24077330 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 May 2025 15:16:40 +0200 Subject: [PATCH 05/16] Open portfolio calculator to overwrite calcluations --- .../calculator/portfolio-calculator.ts | 180 ++++++++++-------- ...rtfolio-calculator-symbolmetrics-helper.ts | 3 + .../calculator/roi/portfolio-calculator.ts | 68 ++++++- 3 files changed, 166 insertions(+), 85 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 9cfbefd08..8fa1a41a8 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -71,8 +71,8 @@ export abstract class PortfolioCalculator { private filters: Filter[]; private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; - private snapshot: PortfolioSnapshot; - private snapshotPromise: Promise; + protected snapshot: PortfolioSnapshot; + protected snapshotPromise: Promise; private startDate: Date; private transactionPoints: TransactionPoint[]; protected userId: string; @@ -557,56 +557,9 @@ export abstract class PortfolioCalculator { } } - const historicalData: HistoricalDataItem[] = Object.entries( + const historicalData: HistoricalDataItem[] = this.getHistoricalDataItems( accumulatedValuesByDate - ).map(([date, values]) => { - const { - investmentValueWithCurrencyEffect, - totalAccountBalanceWithCurrencyEffect, - totalCurrentValue, - totalCurrentValueWithCurrencyEffect, - totalInvestmentValue, - totalInvestmentValueWithCurrencyEffect, - totalNetPerformanceValue, - totalNetPerformanceValueWithCurrencyEffect, - totalTimeWeightedInvestmentValue, - totalTimeWeightedInvestmentValueWithCurrencyEffect - } = values; - - const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(totalTimeWeightedInvestmentValue) - .toNumber(); - - const netPerformanceInPercentageWithCurrencyEffect = - totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) - .toNumber(); - - return { - date, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - investmentValueWithCurrencyEffect: - investmentValueWithCurrencyEffect.toNumber(), - netPerformance: totalNetPerformanceValue.toNumber(), - netPerformanceWithCurrencyEffect: - totalNetPerformanceValueWithCurrencyEffect.toNumber(), - // TODO: Add valuables - netWorth: totalCurrentValueWithCurrencyEffect - .plus(totalAccountBalanceWithCurrencyEffect) - .toNumber(), - totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), - totalInvestment: totalInvestmentValue.toNumber(), - totalInvestmentValueWithCurrencyEffect: - totalInvestmentValueWithCurrencyEffect.toNumber(), - value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() - }; - }); + ); const overall = this.calculateOverallPerformance(positions); @@ -865,39 +818,69 @@ export abstract class PortfolioCalculator { }; } - public getStartDate() { - let firstAccountBalanceDate: Date; - let firstActivityDate: Date; - - try { - const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; - firstAccountBalanceDate = firstAccountBalanceDateString - ? parseDate(firstAccountBalanceDateString) - : new Date(); - } catch (error) { - firstAccountBalanceDate = new Date(); - } - - try { - const firstActivityDateString = this.transactionPoints[0].date; - firstActivityDate = firstActivityDateString - ? parseDate(firstActivityDateString) - : new Date(); - } catch (error) { - firstActivityDate = new Date(); - } - - return min([firstAccountBalanceDate, firstActivityDate]); - } + @LogPerformance + protected getHistoricalDataItems(accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + }): HistoricalDataItem[] { + return Object.entries(accumulatedValuesByDate).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + totalAccountBalanceWithCurrencyEffect, + totalCurrentValue, + totalCurrentValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect + } = values; - public getTransactionPoints() { - return this.transactionPoints; - } + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .toNumber(); - public async getValuablesInBaseCurrency() { - await this.snapshotPromise; + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .toNumber(); - return this.snapshot.totalValuablesWithCurrencyEffect; + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber(), + netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), + // TODO: Add valuables + netWorth: totalCurrentValueWithCurrencyEffect + .plus(totalAccountBalanceWithCurrencyEffect) + .toNumber(), + totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() + }; + }); } @LogPerformance @@ -1281,6 +1264,41 @@ export abstract class PortfolioCalculator { ); } + public getStartDate() { + let firstAccountBalanceDate: Date; + let firstActivityDate: Date; + + try { + const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; + firstAccountBalanceDate = firstAccountBalanceDateString + ? parseDate(firstAccountBalanceDateString) + : new Date(); + } catch (error) { + firstAccountBalanceDate = new Date(); + } + + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } + + return min([firstAccountBalanceDate, firstActivityDate]); + } + + public getTransactionPoints() { + return this.transactionPoints; + } + + public async getValuablesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalValuablesWithCurrencyEffect; + } + private calculateHoldings( investmentByDate: { [date: string]: PortfolioOrder[] }, start: Date, diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts index 9177befb1..2bae172a6 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts @@ -1,3 +1,4 @@ +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { SymbolMetrics } from '@ghostfolio/common/interfaces'; @@ -27,6 +28,7 @@ export class RoiPortfolioCalculatorSymbolMetricsHelper { this.chartDates = chartDates; } + @LogPerformance public calculateNetPerformanceByDateRange( start: Date, symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject @@ -119,6 +121,7 @@ export class RoiPortfolioCalculatorSymbolMetricsHelper { } } + @LogPerformance public processOrderMetrics( orders: PortfolioOrderItem[], i: number, 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 150bacdda..b11896557 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,6 +1,8 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { AssetProfileIdentifier, + HistoricalDataItem, SymbolMetrics } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; @@ -15,6 +17,63 @@ import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculato export class RoiPortfolioCalculator extends PortfolioCalculator { private chartDates: string[]; + + //TODO Overwrite historicalData creation for ROI --> Use TimeWeighted as used for chart + + @LogPerformance + public override async getPerformance({ + end, + start + }: { + end: string | number | Date; + start: string | number | Date; + }): Promise<{ + chart: HistoricalDataItem[]; + netPerformance: number; + netPerformanceInPercentage: number; + netPerformanceWithCurrencyEffect: number; + netPerformanceInPercentageWithCurrencyEffect: number; + netWorth: number; + totalInvestment: number; + valueWithCurrencyEffect: number; + }> { + await this.snapshotPromise; + const { positions } = this.snapshot; + + const { chart } = await super.getPerformance({ start, end }); + + const last = chart.at(-1); + const netWorth = last.netWorth; + const totalInvestment = last.totalInvestment; + const valueWithCurrencyEffect = last.valueWithCurrencyEffect; + + let netPerformance: number; + let netPerformanceInPercentage: number; + let netPerformanceWithCurrencyEffect: number; + let netPerformanceInPercentageWithCurrencyEffect: number; + + for (const position of positions) { + netPerformance = netPerformance + position.netPerformance.toNumber(); + netPerformanceInPercentage = + netPerformanceInPercentage * + position.valueInBaseCurrency.div(netWorth).toNumber(); + + //TODO Calculate performance values not using chart + } + + return { + chart, + netPerformance, + netPerformanceInPercentage, + netPerformanceWithCurrencyEffect, + netPerformanceInPercentageWithCurrencyEffect, + netWorth, + totalInvestment, + valueWithCurrencyEffect + }; + } + + @LogPerformance protected calculateOverallPerformance( positions: TimelinePosition[] ): PortfolioSnapshot { @@ -76,10 +135,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; } - protected getPerformanceCalculationType() { - return PerformanceCalculationType.ROI; - } - + @LogPerformance protected getSymbolMetrics({ chartDateMap, dataSource, @@ -183,6 +239,10 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { return symbolMetricsHelper.symbolMetrics; } + protected getPerformanceCalculationType() { + return PerformanceCalculationType.ROI; + } + private calculatePositionMetrics( currentPosition: TimelinePosition, totalFeesWithCurrencyEffect: Big, From 043a77b71a867946d4dd8ebb07da1b564476b6c2 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 May 2025 15:38:20 +0200 Subject: [PATCH 06/16] Remove log performance + plus further work on performance calculation --- ...rtfolio-calculator-symbolmetrics-helper.ts | 1 - .../calculator/roi/portfolio-calculator.ts | 27 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts index 2bae172a6..656988f63 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts @@ -121,7 +121,6 @@ export class RoiPortfolioCalculatorSymbolMetricsHelper { } } - @LogPerformance public processOrderMetrics( orders: PortfolioOrderItem[], i: number, 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 b11896557..156dff719 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -52,11 +52,34 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { let netPerformanceWithCurrencyEffect: number; let netPerformanceInPercentageWithCurrencyEffect: number; + const totalInvestments = positions.reduce( + (total, position) => { + return { + total: total.total.plus(position.investment), + totalWithCurrencyEffect: total.totalWithCurrencyEffect.plus( + position.investmentWithCurrencyEffect + ) + }; + }, + { total: new Big(0), totalWithCurrencyEffect: new Big(0) } + ); for (const position of positions) { netPerformance = netPerformance + position.netPerformance.toNumber(); + // TODO GET Net performance with currency effect netPerformanceInPercentage = - netPerformanceInPercentage * - position.valueInBaseCurrency.div(netWorth).toNumber(); + netPerformanceInPercentage + + position.netPerformancePercentage + .mul(position.investment.div(totalInvestments.total)) + .toNumber(); + netPerformanceInPercentageWithCurrencyEffect = + netPerformanceInPercentageWithCurrencyEffect + + position.netPerformancePercentage + .mul( + position.investmentWithCurrencyEffect.div( + totalInvestments.totalWithCurrencyEffect + ) + ) + .toNumber(); //TODO Calculate performance values not using chart } From 1f8eded97718f5c7f48bf748146a79b051b27680 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 09:55:09 +0200 Subject: [PATCH 07/16] Add timeweighted Calculation to PortfolioCalculator --- .../portfolio-calculator.factory.ts | 1 + .../calculator/portfolio-calculator.ts | 120 +++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts index fe4b04cff..0c13fb180 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -14,6 +14,7 @@ import { OrderService } from '../../order/order.service'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; +// import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; import { RoiPortfolioCalculator } from './roi/portfolio-calculator'; import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 8fa1a41a8..726a79b74 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -64,19 +64,19 @@ export abstract class PortfolioCalculator { protected configurationService: ConfigurationService; protected currency: string; protected currentRateService: CurrentRateService; - private dataProviderInfos: DataProviderInfo[]; - private endDate: Date; protected exchangeRateDataService: ExchangeRateDataService; protected orderService: OrderService; + protected snapshot: PortfolioSnapshot; + protected snapshotPromise: Promise; + protected userId: string; + protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; + private dataProviderInfos: DataProviderInfo[]; + private endDate: Date; private filters: Filter[]; private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; - protected snapshot: PortfolioSnapshot; - protected snapshotPromise: Promise; private startDate: Date; private transactionPoints: TransactionPoint[]; - protected userId: string; - protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; private holdings: { [date: string]: { [symbol: string]: Big } } = {}; private holdingCurrencies: { [symbol: string]: string } = {}; @@ -833,6 +833,9 @@ export abstract class PortfolioCalculator { totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; }; }): HistoricalDataItem[] { + let previousDateString = ''; + let timeWeightedPerformancePreviousPeriod = new Big(0); + let timeWeightedPerformancePreviousPeriodWithCurrencyEffect = new Big(0); return Object.entries(accumulatedValuesByDate).map(([date, values]) => { const { investmentValueWithCurrencyEffect, @@ -860,6 +863,24 @@ export abstract class PortfolioCalculator { .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) .toNumber(); + let timeWeightedPerformanceInPercentage: number; + let timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + ({ + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + previousDateString, + timeWeightedPerformancePreviousPeriod, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect + } = this.handleTimeWeightedPerformance( + accumulatedValuesByDate, + previousDateString, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + timeWeightedPerformancePreviousPeriod, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect, + date + )); + return { date, netPerformanceInPercentage, @@ -878,7 +899,9 @@ export abstract class PortfolioCalculator { totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect.toNumber(), value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber(), + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect }; }); } @@ -1402,6 +1425,89 @@ export abstract class PortfolioCalculator { return chartDateMap; } + private handleTimeWeightedPerformance( + accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + }, + previousDateString: string, + totalNetPerformanceValue: Big, + totalNetPerformanceValueWithCurrencyEffect: Big, + timeWeightedPerformancePreviousPeriod: Big, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big, + date: string + ): { + timeWeightedPerformanceInPercentage: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + previousDateString: string; + timeWeightedPerformancePreviousPeriod: Big; + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big; + } { + const previousValues = accumulatedValuesByDate[previousDateString] ?? { + totalNetPerformanceValue: new Big(0), + totalNetPerformanceValueWithCurrencyEffect: new Big(0), + totalTimeWeightedInvestmentValue: new Big(0), + totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0) + }; + + const timeWeightedPerformanceCurrentPeriod = this.divideByOrZero( + (div) => + totalNetPerformanceValue + .minus(previousValues.totalNetPerformanceValue) + .div(div), + previousValues.totalTimeWeightedInvestmentValue + ); + const timeWeightedPerformanceCurrentPeriodWithCurrencyEffect = + this.divideByOrZero( + (div) => + totalNetPerformanceValueWithCurrencyEffect + .minus(previousValues.totalNetPerformanceValueWithCurrencyEffect) + .div(div), + previousValues.totalTimeWeightedInvestmentValueWithCurrencyEffect + ); + + const timeWeightedPerformanceInPercentage = new Big(1) + .plus(timeWeightedPerformancePreviousPeriod) + .mul(new Big(1).plus(timeWeightedPerformanceCurrentPeriod)) + .minus(1); + const timeWeightedPerformanceInPercentageWithCurrencyEffect = new Big(1) + .plus(timeWeightedPerformancePreviousPeriodWithCurrencyEffect) + .mul( + new Big(1).plus(timeWeightedPerformanceCurrentPeriodWithCurrencyEffect) + ) + .minus(1); + + return { + timeWeightedPerformanceInPercentage: + timeWeightedPerformanceInPercentage.toNumber(), + timeWeightedPerformanceInPercentageWithCurrencyEffect: + timeWeightedPerformanceInPercentageWithCurrencyEffect.toNumber(), + previousDateString: date, + timeWeightedPerformancePreviousPeriod: + timeWeightedPerformanceInPercentage, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: + timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + } + + private divideByOrZero(fn: (big: Big) => Big, divisor: Big): Big { + if (divisor.eq(0)) { + return new Big(0); + } else { + return fn(divisor); + } + } + protected abstract getSymbolMetrics({ chartDateMap, dataSource, From 28b288e6da798dfcf78d67b2dfdc27ed7c5ece16 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 09:55:51 +0200 Subject: [PATCH 08/16] Rename Property on interface --- .../src/lib/interfaces/historical-data-item.interface.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index 7f785e509..207f0021b 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -16,7 +16,7 @@ export interface HistoricalDataItem { totalInvestmentValueWithCurrencyEffect?: number; value?: number; valueInPercentage?: number; - timeWeightedPerformance?: number; - timeWeightedPerformanceWithCurrencyEffect?: number; + timeWeightedPerformanceInPercentage?: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect?: number; valueWithCurrencyEffect?: number; } From 974186dfe5964cd23bee421e105c72024134b235 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 09:56:32 +0200 Subject: [PATCH 09/16] Allow user to choose between portfolio calculators --- apps/api/src/app/user/update-user-setting.dto.ts | 5 +++++ .../user-account-settings/user-account-settings.html | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index d8e06790b..eb9567630 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -6,6 +6,7 @@ import type { HoldingsViewMode, ViewMode } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { IsArray, @@ -117,4 +118,8 @@ export class UpdateUserSettingDto { @IsOptional() xRayRules?: XRayRulesSettings; + + @IsIn(['TWR', 'ROI', 'ROAI', 'MWR'] as PerformanceCalculationType[]) + @IsOptional() + performanceCalculationType?: PerformanceCalculationType; } 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 72d5aa678..15ad7bf07 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 @@ -34,7 +34,6 @@ Return on Average Investment (ROAI) + Return on Investment (ROI) From f9b14a9c9906b3d3e685059d4ce1ccf80591f423 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 10:15:50 +0200 Subject: [PATCH 10/16] Remove unused method --- .../calculator/roi/portfolio-calculator.ts | 79 ------------------- 1 file changed, 79 deletions(-) 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 156dff719..dc3051909 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -2,7 +2,6 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { AssetProfileIdentifier, - HistoricalDataItem, SymbolMetrics } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; @@ -18,84 +17,6 @@ import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculato export class RoiPortfolioCalculator extends PortfolioCalculator { private chartDates: string[]; - //TODO Overwrite historicalData creation for ROI --> Use TimeWeighted as used for chart - - @LogPerformance - public override async getPerformance({ - end, - start - }: { - end: string | number | Date; - start: string | number | Date; - }): Promise<{ - chart: HistoricalDataItem[]; - netPerformance: number; - netPerformanceInPercentage: number; - netPerformanceWithCurrencyEffect: number; - netPerformanceInPercentageWithCurrencyEffect: number; - netWorth: number; - totalInvestment: number; - valueWithCurrencyEffect: number; - }> { - await this.snapshotPromise; - const { positions } = this.snapshot; - - const { chart } = await super.getPerformance({ start, end }); - - const last = chart.at(-1); - const netWorth = last.netWorth; - const totalInvestment = last.totalInvestment; - const valueWithCurrencyEffect = last.valueWithCurrencyEffect; - - let netPerformance: number; - let netPerformanceInPercentage: number; - let netPerformanceWithCurrencyEffect: number; - let netPerformanceInPercentageWithCurrencyEffect: number; - - const totalInvestments = positions.reduce( - (total, position) => { - return { - total: total.total.plus(position.investment), - totalWithCurrencyEffect: total.totalWithCurrencyEffect.plus( - position.investmentWithCurrencyEffect - ) - }; - }, - { total: new Big(0), totalWithCurrencyEffect: new Big(0) } - ); - for (const position of positions) { - netPerformance = netPerformance + position.netPerformance.toNumber(); - // TODO GET Net performance with currency effect - netPerformanceInPercentage = - netPerformanceInPercentage + - position.netPerformancePercentage - .mul(position.investment.div(totalInvestments.total)) - .toNumber(); - netPerformanceInPercentageWithCurrencyEffect = - netPerformanceInPercentageWithCurrencyEffect + - position.netPerformancePercentage - .mul( - position.investmentWithCurrencyEffect.div( - totalInvestments.totalWithCurrencyEffect - ) - ) - .toNumber(); - - //TODO Calculate performance values not using chart - } - - return { - chart, - netPerformance, - netPerformanceInPercentage, - netPerformanceWithCurrencyEffect, - netPerformanceInPercentageWithCurrencyEffect, - netWorth, - totalInvestment, - valueWithCurrencyEffect - }; - } - @LogPerformance protected calculateOverallPerformance( positions: TimelinePosition[] From 96186dd4d8a3acdb2f2396ae67c70f0d7b12fb5f Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 10:25:02 +0200 Subject: [PATCH 11/16] Use Time-Weighted for performance graph --- .../benchmark-comparator.component.html | 9 ++++++++- .../pages/portfolio/analysis/analysis-page.component.ts | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html index eab89f53d..5d881c604 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -3,7 +3,14 @@
- Performance + Performance + {{ + user?.settings?.performanceCalculationType === 'ROI' + ? '(Time-Weighted)' + : '' + }} @if (user?.subscription?.type === 'Basic') { } diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index cead3827e..12c5ba424 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -17,6 +17,7 @@ import type { DateRange, GroupBy } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { translate } from '@ghostfolio/ui/i18n'; import { Clipboard } from '@angular/cdk/clipboard'; @@ -305,6 +306,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { { date, netPerformanceInPercentageWithCurrencyEffect, + timeWeightedPerformanceInPercentageWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect, valueInPercentage, valueWithCurrencyEffect @@ -325,7 +327,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { } this.performanceDataItemsInPercentage.push({ date, - value: netPerformanceInPercentageWithCurrencyEffect + value: + this.user?.settings?.performanceCalculationType === + PerformanceCalculationType.ROI + ? timeWeightedPerformanceInPercentageWithCurrencyEffect + : netPerformanceInPercentageWithCurrencyEffect }); } From 986fb181b7c3b78c158af6b1b1fd28108ec042b3 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 10:47:01 +0200 Subject: [PATCH 12/16] Reset Graph to 0 at start of time period --- .../calculator/portfolio-calculator.ts | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 726a79b74..d5c660479 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -745,6 +745,10 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; + let lastTimeWeightedPerformancePercentage: number; + let lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; + let timeWeightedPerformanceInPercentage: number; + let timeWeightedPerformanceInPercentageWithCurrencyEffect: number; const totalInvestmentValuesWithCurrencyEffect: number[] = []; for (const historicalDataItem of historicalData) { @@ -777,7 +781,19 @@ export abstract class PortfolioCalculator { totalInvestmentValuesWithCurrencyEffect.length : 0; - //TODO : Extract in abstractFunction and use timeweighted for ROI + Handle total values separately + ({ + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + lastTimeWeightedPerformancePercentage, + lastTimeWeightedPerformancePercentageWithCurrencyEffect + } = this.calculateTimeWeightedPerformance( + lastTimeWeightedPerformancePercentage, + historicalDataItem, + lastTimeWeightedPerformancePercentageWithCurrencyEffect, + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect + )); + chart.push({ ...historicalDataItem, netPerformance: @@ -792,7 +808,9 @@ export abstract class PortfolioCalculator { timeWeightedInvestmentValue === 0 ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / - timeWeightedInvestmentValue + timeWeightedInvestmentValue, + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect // TODO: Add net worth with valuables // netWorth: totalCurrentValueWithCurrencyEffect // .plus(totalAccountBalanceWithCurrencyEffect) @@ -818,6 +836,42 @@ export abstract class PortfolioCalculator { }; } + private calculateTimeWeightedPerformance( + lastTimeWeightedPerformancePercentage: number, + historicalDataItem: HistoricalDataItem, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number, + timeWeightedPerformanceInPercentage: number, + timeWeightedPerformanceInPercentageWithCurrencyEffect: number + ): { + timeWeightedPerformanceInPercentage: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + lastTimeWeightedPerformancePercentage: number; + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; + } { + timeWeightedPerformanceInPercentage = lastTimeWeightedPerformancePercentage + ? (1 + timeWeightedPerformanceInPercentage) * + ((1 + historicalDataItem.timeWeightedPerformanceInPercentage) / + (1 + lastTimeWeightedPerformancePercentage)) - + 1 + : 0; + timeWeightedPerformanceInPercentageWithCurrencyEffect = + lastTimeWeightedPerformancePercentageWithCurrencyEffect + ? (1 + timeWeightedPerformanceInPercentageWithCurrencyEffect) * + ((1 + + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect) / + (1 + lastTimeWeightedPerformancePercentageWithCurrencyEffect)) - + 1 + : 0; + return { + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + lastTimeWeightedPerformancePercentage: + historicalDataItem.timeWeightedPerformanceInPercentage, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + } + @LogPerformance protected getHistoricalDataItems(accumulatedValuesByDate: { [date: string]: { From 9d5dd5507de5804beafb76eefe84f4a2c7513a7e Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 13:23:02 +0200 Subject: [PATCH 13/16] Refactorings --- .../calculator/portfolio-calculator.ts | 121 ++- ...rtfolio-calculator-symbolmetrics-helper.ts | 35 +- .../calculator/roi/portfolio-calculator.ts | 1 - .../src/app/portfolio/portfolio.service.ts | 806 +++++++++--------- 4 files changed, 482 insertions(+), 481 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d5c660479..295577ead 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -621,30 +621,26 @@ export abstract class PortfolioCalculator { {} ); - const totalInvestment = await Object.keys(holdings[endString]).reduce( - (sum, holding) => { - if (!holdings[endString][holding].toNumber()) { - return sum; - } - const symbol = marketMap.values.find((m) => m.symbol === holding); + return Object.keys(holdings[endString]).reduce((sum, holding) => { + if (!holdings[endString][holding].toNumber()) { + return sum; + } + const symbol = marketMap.values.find((m) => m.symbol === holding); - if (symbol?.marketPrice === undefined) { - Logger.warn( - `Missing historical market data for ${holding} (${end})`, - 'PortfolioCalculator' - ); - return sum; - } else { - const symbolCurrency = this.getCurrency(holding); - const price = new Big(currencyRates[symbolCurrency]).mul( - symbol.marketPrice - ); - return sum.plus(new Big(price).mul(holdings[endString][holding])); - } - }, - new Big(0) - ); - return totalInvestment; + if (symbol?.marketPrice === undefined) { + Logger.warn( + `Missing historical market data for ${holding} (${end})`, + 'PortfolioCalculator' + ); + return sum; + } else { + const symbolCurrency = this.getCurrency(holding); + const price = new Big(currencyRates[symbolCurrency]).mul( + symbol.marketPrice + ); + return sum.plus(new Big(price).mul(holdings[endString][holding])); + } + }, new Big(0)); } @LogPerformance @@ -836,42 +832,6 @@ export abstract class PortfolioCalculator { }; } - private calculateTimeWeightedPerformance( - lastTimeWeightedPerformancePercentage: number, - historicalDataItem: HistoricalDataItem, - lastTimeWeightedPerformancePercentageWithCurrencyEffect: number, - timeWeightedPerformanceInPercentage: number, - timeWeightedPerformanceInPercentageWithCurrencyEffect: number - ): { - timeWeightedPerformanceInPercentage: number; - timeWeightedPerformanceInPercentageWithCurrencyEffect: number; - lastTimeWeightedPerformancePercentage: number; - lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; - } { - timeWeightedPerformanceInPercentage = lastTimeWeightedPerformancePercentage - ? (1 + timeWeightedPerformanceInPercentage) * - ((1 + historicalDataItem.timeWeightedPerformanceInPercentage) / - (1 + lastTimeWeightedPerformancePercentage)) - - 1 - : 0; - timeWeightedPerformanceInPercentageWithCurrencyEffect = - lastTimeWeightedPerformancePercentageWithCurrencyEffect - ? (1 + timeWeightedPerformanceInPercentageWithCurrencyEffect) * - ((1 + - historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect) / - (1 + lastTimeWeightedPerformancePercentageWithCurrencyEffect)) - - 1 - : 0; - return { - timeWeightedPerformanceInPercentage, - timeWeightedPerformanceInPercentageWithCurrencyEffect, - lastTimeWeightedPerformancePercentage: - historicalDataItem.timeWeightedPerformanceInPercentage, - lastTimeWeightedPerformancePercentageWithCurrencyEffect: - historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect - }; - } - @LogPerformance protected getHistoricalDataItems(accumulatedValuesByDate: { [date: string]: { @@ -1375,6 +1335,41 @@ export abstract class PortfolioCalculator { return this.snapshot.totalValuablesWithCurrencyEffect; } + private calculateTimeWeightedPerformance( + lastTimeWeightedPerformancePercentage: number, + historicalDataItem: HistoricalDataItem, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number, + timeWeightedPerformanceInPercentage: number, + timeWeightedPerformanceInPercentageWithCurrencyEffect: number + ): { + timeWeightedPerformanceInPercentage: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + lastTimeWeightedPerformancePercentage: number; + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; + } { + timeWeightedPerformanceInPercentage = lastTimeWeightedPerformancePercentage + ? (1 + timeWeightedPerformanceInPercentage) * + ((1 + historicalDataItem.timeWeightedPerformanceInPercentage) / + (1 + lastTimeWeightedPerformancePercentage)) - + 1 + : 0; + timeWeightedPerformanceInPercentageWithCurrencyEffect = + lastTimeWeightedPerformancePercentageWithCurrencyEffect + ? (1 + timeWeightedPerformanceInPercentageWithCurrencyEffect) * + ((1 + + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect) / + (1 + lastTimeWeightedPerformancePercentageWithCurrencyEffect)) - + 1 + : 0; + return { + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + lastTimeWeightedPerformancePercentage: + historicalDataItem.timeWeightedPerformanceInPercentage, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + } private calculateHoldings( investmentByDate: { [date: string]: PortfolioOrder[] }, @@ -1511,7 +1506,9 @@ export abstract class PortfolioCalculator { totalNetPerformanceValue: new Big(0), totalNetPerformanceValueWithCurrencyEffect: new Big(0), totalTimeWeightedInvestmentValue: new Big(0), - totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0) + totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0), + totalCurrentValue: new Big(0), + totalCurrentValueWithCurrencyEffect: new Big(0) }; const timeWeightedPerformanceCurrentPeriod = this.divideByOrZero( @@ -1519,7 +1516,7 @@ export abstract class PortfolioCalculator { totalNetPerformanceValue .minus(previousValues.totalNetPerformanceValue) .div(div), - previousValues.totalTimeWeightedInvestmentValue + previousValues.totalCurrentValue ); const timeWeightedPerformanceCurrentPeriodWithCurrencyEffect = this.divideByOrZero( @@ -1527,7 +1524,7 @@ export abstract class PortfolioCalculator { totalNetPerformanceValueWithCurrencyEffect .minus(previousValues.totalNetPerformanceValueWithCurrencyEffect) .div(div), - previousValues.totalTimeWeightedInvestmentValueWithCurrencyEffect + previousValues.totalCurrentValueWithCurrencyEffect ); const timeWeightedPerformanceInPercentage = new Big(1) diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts index 656988f63..8d02dacc4 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts @@ -1,4 +1,3 @@ -import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { SymbolMetrics } from '@ghostfolio/common/interfaces'; @@ -28,7 +27,6 @@ export class RoiPortfolioCalculatorSymbolMetricsHelper { this.chartDates = chartDates; } - @LogPerformance public calculateNetPerformanceByDateRange( start: Date, symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject @@ -61,21 +59,28 @@ export class RoiPortfolioCalculatorSymbolMetricsHelper { new Big(0)) ) ?? new Big(0); + let investmentBasis = + symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ + rangeStartDateString + ]; + + if ( + !symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ + rangeStartDateString + ]?.gt(0) + ) { + investmentBasis = + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString]; + } + symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[ dateRange - ] = - symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[ - rangeEndDateString - ]?.gt(0) - ? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ - dateRange - ].div( - symbolMetricsHelper.symbolMetrics - .timeWeightedInvestmentValuesWithCurrencyEffect[ - rangeEndDateString - ] - ) - : new Big(0); + ] = investmentBasis.gt(0) + ? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ + dateRange + ].div(investmentBasis) + : new Big(0); } } 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 dc3051909..faef77712 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -79,7 +79,6 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; } - @LogPerformance protected getSymbolMetrics({ chartDateMap, dataSource, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 96c89fc63..d93767ab2 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1356,145 +1356,6 @@ export class PortfolioService { await this.orderService.assignTags({ dataSource, symbol, tags, userId }); } - private getAggregatedMarkets(holdings: Record): { - markets: PortfolioDetails['markets']; - marketsAdvanced: PortfolioDetails['marketsAdvanced']; - } { - const markets: PortfolioDetails['markets'] = { - [UNKNOWN_KEY]: { - id: UNKNOWN_KEY, - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - developedMarkets: { - id: 'developedMarkets', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - emergingMarkets: { - id: 'emergingMarkets', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - otherMarkets: { - id: 'otherMarkets', - valueInBaseCurrency: 0, - valueInPercentage: 0 - } - }; - - const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = { - [UNKNOWN_KEY]: { - id: UNKNOWN_KEY, - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - asiaPacific: { - id: 'asiaPacific', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - emergingMarkets: { - id: 'emergingMarkets', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - europe: { - id: 'europe', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - japan: { - id: 'japan', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - northAmerica: { - id: 'northAmerica', - valueInBaseCurrency: 0, - valueInPercentage: 0 - }, - otherMarkets: { - id: 'otherMarkets', - valueInBaseCurrency: 0, - valueInPercentage: 0 - } - }; - - for (const [, position] of Object.entries(holdings)) { - const value = position.valueInBaseCurrency; - - if (position.assetClass !== AssetClass.LIQUIDITY) { - if (position.countries.length > 0) { - markets.developedMarkets.valueInBaseCurrency += - position.markets.developedMarkets * value; - markets.emergingMarkets.valueInBaseCurrency += - position.markets.emergingMarkets * value; - markets.otherMarkets.valueInBaseCurrency += - position.markets.otherMarkets * value; - - marketsAdvanced.asiaPacific.valueInBaseCurrency += - position.marketsAdvanced.asiaPacific * value; - marketsAdvanced.emergingMarkets.valueInBaseCurrency += - position.marketsAdvanced.emergingMarkets * value; - marketsAdvanced.europe.valueInBaseCurrency += - position.marketsAdvanced.europe * value; - marketsAdvanced.japan.valueInBaseCurrency += - position.marketsAdvanced.japan * value; - marketsAdvanced.northAmerica.valueInBaseCurrency += - position.marketsAdvanced.northAmerica * value; - marketsAdvanced.otherMarkets.valueInBaseCurrency += - position.marketsAdvanced.otherMarkets * value; - } else { - markets[UNKNOWN_KEY].valueInBaseCurrency += value; - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; - } - } - } - - const marketsTotalInBaseCurrency = getSum( - Object.values(markets).map(({ valueInBaseCurrency }) => { - return new Big(valueInBaseCurrency); - }) - ).toNumber(); - - markets.developedMarkets.valueInPercentage = - markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; - markets.emergingMarkets.valueInPercentage = - markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; - markets.otherMarkets.valueInPercentage = - markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; - markets[UNKNOWN_KEY].valueInPercentage = - markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency; - - const marketsAdvancedTotal = - marketsAdvanced.asiaPacific.valueInBaseCurrency + - marketsAdvanced.emergingMarkets.valueInBaseCurrency + - marketsAdvanced.europe.valueInBaseCurrency + - marketsAdvanced.japan.valueInBaseCurrency + - marketsAdvanced.northAmerica.valueInBaseCurrency + - marketsAdvanced.otherMarkets.valueInBaseCurrency + - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency; - - marketsAdvanced.asiaPacific.valueInPercentage = - marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal; - marketsAdvanced.emergingMarkets.valueInPercentage = - marketsAdvanced.emergingMarkets.valueInBaseCurrency / - marketsAdvancedTotal; - marketsAdvanced.europe.valueInPercentage = - marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal; - marketsAdvanced.japan.valueInPercentage = - marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal; - marketsAdvanced.northAmerica.valueInPercentage = - marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal; - marketsAdvanced.otherMarkets.valueInPercentage = - marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal; - marketsAdvanced[UNKNOWN_KEY].valueInPercentage = - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal; - - return { markets, marketsAdvanced }; - } - @LogPerformance private async getCashPositions({ cashDetails, @@ -1546,68 +1407,6 @@ export class PortfolioService { return cashPositions; } - private getDividendsByGroup({ - dividends, - groupBy - }: { - dividends: InvestmentItem[]; - groupBy: GroupBy; - }): InvestmentItem[] { - if (dividends.length === 0) { - return []; - } - - const dividendsByGroup: InvestmentItem[] = []; - let currentDate: Date; - let investmentByGroup = new Big(0); - - for (const [index, dividend] of dividends.entries()) { - if ( - isSameYear(parseDate(dividend.date), currentDate) && - (groupBy === 'year' || - isSameMonth(parseDate(dividend.date), currentDate)) - ) { - // Same group: Add up dividends - - investmentByGroup = investmentByGroup.plus(dividend.investment); - } else { - // New group: Store previous group and reset - - if (currentDate) { - dividendsByGroup.push({ - date: format( - set(currentDate, { - date: 1, - month: groupBy === 'year' ? 0 : currentDate.getMonth() - }), - DATE_FORMAT - ), - investment: investmentByGroup.toNumber() - }); - } - - currentDate = parseDate(dividend.date); - investmentByGroup = new Big(dividend.investment); - } - - if (index === dividends.length - 1) { - // Store current month (latest order) - dividendsByGroup.push({ - date: format( - set(currentDate, { - date: 1, - month: groupBy === 'year' ? 0 : currentDate.getMonth() - }), - DATE_FORMAT - ), - investment: investmentByGroup.toNumber() - }); - } - } - - return dividendsByGroup; - } - @LogPerformance private getEmergencyFundHoldingsValueInBaseCurrency({ holdings @@ -1634,145 +1433,23 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber(); } - private getInitialCashPosition({ - balance, - currency - }: { - balance: number; - currency: string; - }): PortfolioPosition { - return { - currency, - allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, - countries: [], - dataSource: undefined, - dateOfFirstActivity: undefined, - dividend: 0, - grossPerformance: 0, - grossPerformancePercent: 0, - grossPerformancePercentWithCurrencyEffect: 0, - grossPerformanceWithCurrencyEffect: 0, - holdings: [], - investment: balance, - marketPrice: 0, - name: currency, - netPerformance: 0, - netPerformancePercent: 0, - netPerformancePercentWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - quantity: 0, - sectors: [], - symbol: currency, - tags: [], - transactionCount: 0, - valueInBaseCurrency: balance - }; - } - - private getMarkets({ - assetProfile - }: { - assetProfile: EnhancedSymbolProfile; - }) { - const markets = { - [UNKNOWN_KEY]: 0, - developedMarkets: 0, - emergingMarkets: 0, - otherMarkets: 0 - }; - const marketsAdvanced = { - [UNKNOWN_KEY]: 0, - asiaPacific: 0, - emergingMarkets: 0, - europe: 0, - japan: 0, - northAmerica: 0, - otherMarkets: 0 - }; - - if (assetProfile.countries.length > 0) { - for (const country of assetProfile.countries) { - if (developedMarkets.includes(country.code)) { - markets.developedMarkets = new Big(markets.developedMarkets) - .plus(country.weight) - .toNumber(); - } else if (emergingMarkets.includes(country.code)) { - markets.emergingMarkets = new Big(markets.emergingMarkets) - .plus(country.weight) - .toNumber(); - } else { - markets.otherMarkets = new Big(markets.otherMarkets) - .plus(country.weight) - .toNumber(); - } - - if (country.code === 'JP') { - marketsAdvanced.japan = new Big(marketsAdvanced.japan) - .plus(country.weight) - .toNumber(); - } else if (country.code === 'CA' || country.code === 'US') { - marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica) - .plus(country.weight) - .toNumber(); - } else if (asiaPacificMarkets.includes(country.code)) { - marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific) - .plus(country.weight) - .toNumber(); - } else if (emergingMarkets.includes(country.code)) { - marketsAdvanced.emergingMarkets = new Big( - marketsAdvanced.emergingMarkets - ) - .plus(country.weight) - .toNumber(); - } else if (europeMarkets.includes(country.code)) { - marketsAdvanced.europe = new Big(marketsAdvanced.europe) - .plus(country.weight) - .toNumber(); - } else { - marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets) - .plus(country.weight) - .toNumber(); - } - } - } - - markets[UNKNOWN_KEY] = new Big(1) - .minus(markets.developedMarkets) - .minus(markets.emergingMarkets) - .minus(markets.otherMarkets) - .toNumber(); - - marketsAdvanced[UNKNOWN_KEY] = new Big(1) - .minus(marketsAdvanced.asiaPacific) - .minus(marketsAdvanced.emergingMarkets) - .minus(marketsAdvanced.europe) - .minus(marketsAdvanced.japan) - .minus(marketsAdvanced.northAmerica) - .minus(marketsAdvanced.otherMarkets) - .toNumber(); - - return { markets, marketsAdvanced }; - } - - @LogPerformance - private getReportStatistics( - evaluatedRules: PortfolioReportResponse['rules'] - ): PortfolioReportResponse['statistics'] { - const rulesActiveCount = Object.values(evaluatedRules) - .flat() - .filter((rule) => { - return rule?.isActive === true; - }).length; - - const rulesFulfilledCount = Object.values(evaluatedRules) - .flat() - .filter((rule) => { - return rule?.value === true; - }).length; - - return { rulesActiveCount, rulesFulfilledCount }; + @LogPerformance + private getReportStatistics( + evaluatedRules: PortfolioReportResponse['rules'] + ): PortfolioReportResponse['statistics'] { + const rulesActiveCount = Object.values(evaluatedRules) + .flat() + .filter((rule) => { + return rule?.isActive === true; + }).length; + + const rulesFulfilledCount = Object.values(evaluatedRules) + .flat() + .filter((rule) => { + return rule?.value === true; + }).length; + + return { rulesActiveCount, rulesFulfilledCount }; } @LogPerformance @@ -1981,69 +1658,6 @@ export class PortfolioService { }; } - @LogPerformance - private getSumOfActivityType({ - activities, - activityType, - userCurrency - }: { - activities: Activity[]; - activityType: ActivityType; - userCurrency: string; - }) { - return getSum( - activities - .filter(({ isDraft, type }) => { - return isDraft === false && type === activityType; - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - userCurrency - ) - ); - }) - ); - } - - private getTotalEmergencyFund({ - emergencyFundHoldingsValueInBaseCurrency, - userSettings - }: { - emergencyFundHoldingsValueInBaseCurrency: number; - userSettings: UserSettings; - }) { - return new Big( - Math.max( - emergencyFundHoldingsValueInBaseCurrency, - userSettings?.emergencyFund ?? 0 - ) - ); - } - - private getUserCurrency(aUser?: UserWithSettings) { - return ( - aUser?.Settings?.settings.baseCurrency ?? - this.request.user?.Settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } - - private getUserPerformanceCalculationType( - aUser: UserWithSettings - ): PerformanceCalculationType { - return aUser?.Settings?.settings.performanceCalculationType; - } - @LogPerformance private async getValueOfAccountsAndPlatforms({ activities, @@ -2176,4 +1790,390 @@ export class PortfolioService { return { accounts, platforms }; } + + @LogPerformance + private getSumOfActivityType({ + activities, + activityType, + userCurrency + }: { + activities: Activity[]; + activityType: ActivityType; + userCurrency: string; + }) { + return getSum( + activities + .filter(({ isDraft, type }) => { + return isDraft === false && type === activityType; + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ) + ); + }) + ); + } + + private getInitialCashPosition({ + balance, + currency + }: { + balance: number; + currency: string; + }): PortfolioPosition { + return { + currency, + allocationInPercentage: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + dataSource: undefined, + dateOfFirstActivity: undefined, + dividend: 0, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + holdings: [], + investment: balance, + marketPrice: 0, + name: currency, + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + quantity: 0, + sectors: [], + symbol: currency, + tags: [], + transactionCount: 0, + valueInBaseCurrency: balance + }; + } + + private getDividendsByGroup({ + dividends, + groupBy + }: { + dividends: InvestmentItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + if (dividends.length === 0) { + return []; + } + + const dividendsByGroup: InvestmentItem[] = []; + let currentDate: Date; + let investmentByGroup = new Big(0); + + for (const [index, dividend] of dividends.entries()) { + if ( + isSameYear(parseDate(dividend.date), currentDate) && + (groupBy === 'year' || + isSameMonth(parseDate(dividend.date), currentDate)) + ) { + // Same group: Add up dividends + + investmentByGroup = investmentByGroup.plus(dividend.investment); + } else { + // New group: Store previous group and reset + + if (currentDate) { + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + + currentDate = parseDate(dividend.date); + investmentByGroup = new Big(dividend.investment); + } + + if (index === dividends.length - 1) { + // Store current month (latest order) + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + } + + return dividendsByGroup; + } + + private getMarkets({ + assetProfile + }: { + assetProfile: EnhancedSymbolProfile; + }) { + const markets = { + [UNKNOWN_KEY]: 0, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }; + const marketsAdvanced = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; + + if (assetProfile.countries.length > 0) { + for (const country of assetProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .plus(country.weight) + .toNumber(); + } + + if (country.code === 'JP') { + marketsAdvanced.japan = new Big(marketsAdvanced.japan) + .plus(country.weight) + .toNumber(); + } else if (country.code === 'CA' || country.code === 'US') { + marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica) + .plus(country.weight) + .toNumber(); + } else if (asiaPacificMarkets.includes(country.code)) { + marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + marketsAdvanced.emergingMarkets = new Big( + marketsAdvanced.emergingMarkets + ) + .plus(country.weight) + .toNumber(); + } else if (europeMarkets.includes(country.code)) { + marketsAdvanced.europe = new Big(marketsAdvanced.europe) + .plus(country.weight) + .toNumber(); + } else { + marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets) + .plus(country.weight) + .toNumber(); + } + } + } + + markets[UNKNOWN_KEY] = new Big(1) + .minus(markets.developedMarkets) + .minus(markets.emergingMarkets) + .minus(markets.otherMarkets) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(1) + .minus(marketsAdvanced.asiaPacific) + .minus(marketsAdvanced.emergingMarkets) + .minus(marketsAdvanced.europe) + .minus(marketsAdvanced.japan) + .minus(marketsAdvanced.northAmerica) + .minus(marketsAdvanced.otherMarkets) + .toNumber(); + + return { markets, marketsAdvanced }; + } + + private getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings + }: { + emergencyFundHoldingsValueInBaseCurrency: number; + userSettings: UserSettings; + }) { + return new Big( + Math.max( + emergencyFundHoldingsValueInBaseCurrency, + userSettings?.emergencyFund ?? 0 + ) + ); + } + + private getUserCurrency(aUser?: UserWithSettings) { + return ( + aUser?.Settings?.settings.baseCurrency ?? + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + + private getUserPerformanceCalculationType( + aUser: UserWithSettings + ): PerformanceCalculationType { + return aUser?.Settings?.settings.performanceCalculationType; + } + + private getAggregatedMarkets(holdings: Record): { + markets: PortfolioDetails['markets']; + marketsAdvanced: PortfolioDetails['marketsAdvanced']; + } { + const markets: PortfolioDetails['markets'] = { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + developedMarkets: { + id: 'developedMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + } + }; + + const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + asiaPacific: { + id: 'asiaPacific', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + europe: { + id: 'europe', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + japan: { + id: 'japan', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + northAmerica: { + id: 'northAmerica', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + } + }; + + for (const [, position] of Object.entries(holdings)) { + const value = position.valueInBaseCurrency; + + if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.countries.length > 0) { + markets.developedMarkets.valueInBaseCurrency += + position.markets.developedMarkets * value; + markets.emergingMarkets.valueInBaseCurrency += + position.markets.emergingMarkets * value; + markets.otherMarkets.valueInBaseCurrency += + position.markets.otherMarkets * value; + + marketsAdvanced.asiaPacific.valueInBaseCurrency += + position.marketsAdvanced.asiaPacific * value; + marketsAdvanced.emergingMarkets.valueInBaseCurrency += + position.marketsAdvanced.emergingMarkets * value; + marketsAdvanced.europe.valueInBaseCurrency += + position.marketsAdvanced.europe * value; + marketsAdvanced.japan.valueInBaseCurrency += + position.marketsAdvanced.japan * value; + marketsAdvanced.northAmerica.valueInBaseCurrency += + position.marketsAdvanced.northAmerica * value; + marketsAdvanced.otherMarkets.valueInBaseCurrency += + position.marketsAdvanced.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].valueInBaseCurrency += value; + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; + } + } + } + + const marketsTotalInBaseCurrency = getSum( + Object.values(markets).map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency); + }) + ).toNumber(); + + markets.developedMarkets.valueInPercentage = + markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets.emergingMarkets.valueInPercentage = + markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets.otherMarkets.valueInPercentage = + markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets[UNKNOWN_KEY].valueInPercentage = + markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency; + + const marketsAdvancedTotal = + marketsAdvanced.asiaPacific.valueInBaseCurrency + + marketsAdvanced.emergingMarkets.valueInBaseCurrency + + marketsAdvanced.europe.valueInBaseCurrency + + marketsAdvanced.japan.valueInBaseCurrency + + marketsAdvanced.northAmerica.valueInBaseCurrency + + marketsAdvanced.otherMarkets.valueInBaseCurrency + + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency; + + marketsAdvanced.asiaPacific.valueInPercentage = + marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.emergingMarkets.valueInPercentage = + marketsAdvanced.emergingMarkets.valueInBaseCurrency / + marketsAdvancedTotal; + marketsAdvanced.europe.valueInPercentage = + marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.japan.valueInPercentage = + marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.northAmerica.valueInPercentage = + marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.otherMarkets.valueInPercentage = + marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced[UNKNOWN_KEY].valueInPercentage = + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal; + + return { markets, marketsAdvanced }; + } } From 4169391c40c8020c8873bc77b6f3683228ca2615 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 14:01:42 +0200 Subject: [PATCH 14/16] Update summary page --- .../portfolio-summary.component.html | 12 +++++----- .../portfolio-summary.component.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 1a52bd646..cc6b5283c 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 @@ -115,11 +115,13 @@
Net Performance - (ROAI) + + ({{ this.calculationType.value }}) +
Date: Sat, 3 May 2025 14:24:07 +0200 Subject: [PATCH 15/16] Fix Holdings Table --- .../src/app/portfolio/portfolio.controller.ts | 10 +++++++++- .../home-holdings/home-holdings.component.ts | 5 ++++- .../components/home-holdings/home-holdings.html | 1 + .../portfolio-holdings-response.interface.ts | 6 +++++- .../holdings-table/holdings-table.component.html | 2 +- .../holdings-table/holdings-table.component.ts | 16 ++++++---------- 6 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 92371fb26..0dda4dd3b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -412,6 +412,14 @@ export class PortfolioController { filterByTags }); + const { performance } = await this.portfolioService.getPerformance({ + dateRange, + filters, + impersonationId, + withExcludedAccounts: false, + userId: this.request.user.id + }); + const { holdings } = await this.portfolioService.getDetails({ dateRange, filters, @@ -419,7 +427,7 @@ export class PortfolioController { userId: this.request.user.id }); - return { holdings: Object.values(holdings) }; + return { holdings: Object.values(holdings), performance }; } @Get('investments') diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index dd411f6cc..476aa9592 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { AssetProfileIdentifier, PortfolioPosition, + PortfolioPerformance, ToggleOption, User } from '@ghostfolio/common/interfaces'; @@ -31,6 +32,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { public hasPermissionToAccessHoldingsChart: boolean; public hasPermissionToCreateOrder: boolean; public holdings: PortfolioPosition[]; + public performance: PortfolioPerformance; public holdingType: HoldingType = 'ACTIVE'; public holdingTypeOptions: ToggleOption[] = [ { label: $localize`Active`, value: 'ACTIVE' }, @@ -162,8 +164,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.fetchHoldings() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ holdings }) => { + .subscribe(({ holdings, performance }) => { this.holdings = holdings; + this.performance = performance; this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index f981e50a1..fe0dc6ec3 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -50,6 +50,7 @@ [deviceType]="deviceType" [holdings]="holdings" [locale]="user?.settings?.locale" + [performance]="performance" (holdingClicked)="onHoldingClicked($event)" /> @if (hasPermissionToCreateOrder && holdings?.length > 0) { diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts index d2cf38f55..68a6934d4 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts @@ -1,5 +1,9 @@ -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { + PortfolioPosition, + PortfolioPerformance +} from '@ghostfolio/common/interfaces'; export interface PortfolioHoldingsResponse { holdings: PortfolioPosition[]; + performance: PortfolioPerformance; } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index d187d7dc7..33376a317 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -252,7 +252,7 @@
diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index beb2f62d1..498df72c5 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -3,7 +3,8 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { getLocale } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, - PortfolioPosition + PortfolioPosition, + PortfolioPerformance } from '@ghostfolio/common/interfaces'; import { GfValueComponent } from '@ghostfolio/ui/value'; @@ -55,6 +56,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy { @Input() holdings: PortfolioPosition[]; @Input() locale = getLocale(); @Input() pageSize = Number.MAX_SAFE_INTEGER; + @Input() performance: PortfolioPerformance; @Output() holdingClicked = new EventEmitter(); @@ -94,16 +96,10 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy { this.dataSource = new MatTableDataSource(this.holdings); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; - this.totalValue = this.dataSource.data.reduce( - (sum, current) => (sum += current.valueInBaseCurrency), - 0 - ); - this.totalChange = this.dataSource.data.reduce( - (sum, current) => (sum += current.netPerformanceWithCurrencyEffect), - 0 - ); + this.totalValue = this.performance.currentValueInBaseCurrency; + this.totalChange = this.performance.netPerformanceWithCurrencyEffect; this.totalChangePercentage = - this.totalChange / (this.totalValue - this.totalChange); + this.performance.netPerformancePercentageWithCurrencyEffect; if (this.holdings) { this.isLoading = false; From d3c51a68ac48d68850114d8c1219ffc4a5228ce0 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 3 May 2025 14:34:22 +0200 Subject: [PATCH 16/16] Fix tests --- .../roai/portfolio-calculator-novn-buy-and-sell.spec.ts | 6 ++++++ .../roi/portfolio-calculator-novn-buy-and-sell.spec.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index e1f98d293..0c691f7b2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -139,6 +139,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, @@ -153,6 +155,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, netWorth: 151.6, totalAccountBalance: 0, totalInvestment: 151.6, @@ -172,6 +176,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.13100263852242744, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, netPerformanceWithCurrencyEffect: 19.86, + timeWeightedPerformanceInPercentage: 0.13100263852242744, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, netWorth: 0, totalAccountBalance: 0, totalInvestment: 0, diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts index 304ab4617..dc0939f23 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -139,6 +139,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, @@ -153,6 +155,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, netWorth: 151.6, totalAccountBalance: 0, totalInvestment: 151.6, @@ -171,6 +175,8 @@ describe('PortfolioCalculator', () => { netPerformance: 19.86, netPerformanceInPercentage: 0.13100263852242744, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + timeWeightedPerformanceInPercentage: 0.13100263852242744, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, netPerformanceWithCurrencyEffect: 19.86, netWorth: 0, totalAccountBalance: 0,