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' +];