diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 7d0c2ddda9..3b030a96fc 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -101,6 +101,9 @@ export abstract class PortfolioCalculator { private dataProviderInfos: DataProviderInfo[]; private endDate: Date; private exchangeRateDataService: ExchangeRateDataService; + private exchangeRatesByCurrency: { + [currencyPair: string]: { [date: string]: number }; + } = {}; private filters: Filter[]; private holdingBalancesByDate: HoldingBalancesAtDate[]; private portfolioSnapshotService: PortfolioSnapshotService; @@ -308,7 +311,7 @@ export abstract class PortfolioCalculator { currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; } - const exchangeRatesByCurrency = + this.exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: Array.from(new Set(Object.values(currencies))), endDate: this.endDate, @@ -316,6 +319,8 @@ export abstract class PortfolioCalculator { targetCurrency: this.currency }); + const exchangeRatesByCurrency = this.exchangeRatesByCurrency; + const { dataProviderInfos, errors: currentRateErrors, @@ -905,6 +910,14 @@ export abstract class PortfolioCalculator { return this.dataProviderInfos; } + protected getBaseCurrency() { + return this.currency; + } + + protected getExchangeRatesByCurrency() { + return this.exchangeRatesByCurrency; + } + protected getDividendInBaseCurrencyOfHoldings( holdings: PortfolioSnapshotHolding[] ) { diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts new file mode 100644 index 0000000000..df30d78ed8 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts @@ -0,0 +1,986 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { eachDayOfInterval, isSameDay } from 'date-fns'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +function createActivity({ + currency = 'USD', + dataSource = 'YAHOO', + date, + fee = 0, + name = 'TWR Test Asset', + quantity, + symbol = 'TWR_TEST', + type, + unitPrice +}: { + currency?: string; + dataSource?: Activity['assetProfile']['dataSource']; + date: string; + fee?: number; + name?: string; + quantity: number; + symbol?: string; + type: Activity['type']; + unitPrice: number; +}): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency, + dataSource, + name, + symbol + }, + date: parseDate(date), + feeInAssetProfileCurrency: fee, + feeInBaseCurrency: fee, + quantity, + type, + unitPriceInAssetProfileCurrency: unitPrice + }; +} + +function mockMarketPrices({ + currentRateService, + getMarketPrice +}: { + currentRateService: CurrentRateService; + getMarketPrice: ({ + date, + symbol + }: { + date: Date; + symbol: string; + }) => number | undefined; +}) { + jest + .spyOn(currentRateService, 'getValues') + .mockImplementation(({ dataGatheringItems, dateQuery }) => { + const values = []; + + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { + for (const { dataSource, symbol } of dataGatheringItems) { + const marketPrice = getMarketPrice({ date, symbol }); + + if (marketPrice !== undefined) { + values.push({ + date, + dataSource, + symbol, + marketPrice + }); + } + } + } + + return Promise.resolve({ + values, + dataProviderInfos: [], + errors: [] + }); + }); +} + +describe('PortfolioCalculator (TWR)', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + it('calculates a 10 percent return without external cash flows', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'TWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2025-01-02'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('ignores an external cash inflow when there is no investment return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + + it('chain-links returns across an external cash inflow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-03').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1000 / 110, + type: 'BUY', + unitPrice: 110 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'TWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2025-01-02'), date)) { + return 110; + } + + if (isSameDay(parseDate('2025-01-03'), date)) { + return 121; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.21, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap.max.toNumber() + ).toBeCloseTo(0.21, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap[ + '1d' + ].toNumber() + ).toBeCloseTo(0.1, 12); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2025-01-03'), + start: parseDate('2025-01-02') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('ignores an external cash outflow when there is no investment return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 2, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'SELL', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + + it('treats a same-day sell and buy across holdings as an internal transfer', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + name: 'TWR Test Asset A', + quantity: 2, + symbol: 'TWR_A', + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + name: 'TWR Test Asset A', + quantity: 1, + symbol: 'TWR_A', + type: 'SELL', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + name: 'TWR Test Asset B', + quantity: 1, + symbol: 'TWR_B', + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return ['TWR_A', 'TWR_B'].includes(symbol) ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + + it('aggregates multiple same-day buys of the same holding as external cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 2, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + }); + + it('treats dividends as investment return instead of external cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'DIVIDEND', + unitPrice: 10 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber() + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(0.1, 12); + }); + + it('chain-links the dividend component across multiple periods', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-03').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'DIVIDEND', + unitPrice: 10 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'TWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2025-01-02'), date)) { + return 110; + } + + if (isSameDay(parseDate('2025-01-03'), date)) { + return 121; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.32, 12); + + expect( + latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0.11, 12); + + expect( + (latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect ?? + 0) - + (latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect ?? 0) + ).toBeCloseTo(0.21, 12); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2025-01-03'), + start: parseDate('2025-01-02') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + + expect(chart.at(-1)?.dividendInPercentageWithCurrencyEffect).toBeCloseTo( + 0, + 12 + ); + }); + + it('treats fees as negative investment return instead of external cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + fee: 10, + quantity: 0, + type: 'FEE', + unitPrice: 0 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(-0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(-0.1, 12); + }); + + it('includes a standalone fee in portfolio return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + dataSource: 'MANUAL', + date: '2025-01-02', + fee: 10, + name: 'Account Fee', + quantity: 0, + symbol: 'TWR_STANDALONE_FEE', + type: 'FEE', + unitPrice: 0 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.positions).toHaveLength(1); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(-0.1, 12); + }); + + it('includes standalone interest in portfolio return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + dataSource: 'MANUAL', + date: '2025-01-02', + name: 'Interest Income', + quantity: 1, + symbol: 'TWR_STANDALONE_INTEREST', + type: 'INTEREST', + unitPrice: 10 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.positions).toHaveLength(1); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('treats interest as investment return instead of external cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'INTEREST', + unitPrice: 10 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + }); + + it('preserves investment return when a position is fully closed', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + quantity: 1, + type: 'SELL', + unitPrice: 110 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'TWR_TEST') { + return undefined; + } + + return isSameDay(date, parseDate('2025-01-01')) ? 100 : 110; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber() + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.1, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(0.1, 12); + }); + + it('calculates a new date range after a cumulative 100 percent loss', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-04').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-01-02', + fee: 100, + quantity: 0, + type: 'FEE', + unitPrice: 0 + }), + createActivity({ + date: '2025-01-03', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'TWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2025-01-04'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(-1, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap[ + '1d' + ].toNumber() + ).toBeCloseTo(0.1, 12); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2025-01-04'), + start: parseDate('2025-01-03') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('separates investment return from currency effect', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime()); + + const activities: Activity[] = [ + createActivity({ + currency: 'EUR', + date: '2025-01-01', + name: 'TWR EUR Test Asset', + quantity: 1, + symbol: 'TWR_EUR_TEST', + type: 'BUY', + unitPrice: 100 + }) + ]; + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + EURUSD: { + '2025-01-01': 1.1, + '2025-01-02': 1.2 + } + }); + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'TWR_EUR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect(latestHistoricalDataItem?.netPerformanceInPercentage).toBeCloseTo( + 0, + 12 + ); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(1.2 / 1.1 - 1, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + + expect( + portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(1.2 / 1.1 - 1, 12); + + expect( + portfolioSnapshot.positions[0].netPerformancePercentage.toNumber() + ).toBeCloseTo(0, 12); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index cad53c31ab..0d2e0debdb 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -1,28 +1,240 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; +import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; +import { Big } from 'big.js'; + export class TwrPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance(): PortfolioSnapshot { - throw new Error('Method not implemented.'); + protected calculateOverallPerformance({ + accumulatedValuesByDate, + positions + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + positions: PortfolioCalculatorHolding[]; + }): PortfolioSnapshot { + const latestAccumulatedValues = Object.values(accumulatedValuesByDate).at( + -1 + ); + + return this.getOverallPerformance({ + investedCapital: + latestAccumulatedValues?.totalInvestmentValue ?? new Big(0), + investedCapitalWithCurrencyEffect: + latestAccumulatedValues?.totalInvestmentValueWithCurrencyEffect ?? + new Big(0), + positions + }); } - protected calculatePerformancePercentages(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentages({ + accumulatedValuesByDate + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }): { [date: string]: PerformancePercentages } { + const lastDate = Object.keys(accumulatedValuesByDate).at(-1); + + const { + cashFlowsByDate, + dividendAdjustmentsByDate, + performanceAdjustmentsByDate + } = this.getPortfolioAdjustmentsByDate({ + currentExchangeRateDate: lastDate + }); + + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + let cumulativeReturn = new Big(1); + let cumulativeReturnWithCurrencyEffect = new Big(1); + let cumulativeReturnWithoutDividendsWithCurrencyEffect = new Big(1); + let previousValue = new Big(0); + let previousValueWithCurrencyEffect = new Big(0); + + for (const [date, accumulatedValues] of Object.entries( + accumulatedValuesByDate + )) { + const cashFlow = cashFlowsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const performanceAdjustment = performanceAdjustmentsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const dividendAdjustment = dividendAdjustmentsByDate[date] ?? new Big(0); + + if (previousValue.gt(0)) { + cumulativeReturn = cumulativeReturn.mul( + accumulatedValues.totalCurrentValue + .minus(cashFlow.value) + .plus(performanceAdjustment.value) + .div(previousValue) + ); + } + + if (previousValueWithCurrencyEffect.gt(0)) { + cumulativeReturnWithCurrencyEffect = + cumulativeReturnWithCurrencyEffect.mul( + accumulatedValues.totalCurrentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(performanceAdjustment.valueWithCurrencyEffect) + .div(previousValueWithCurrencyEffect) + ); + + cumulativeReturnWithoutDividendsWithCurrencyEffect = + cumulativeReturnWithoutDividendsWithCurrencyEffect.mul( + accumulatedValues.totalCurrentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(performanceAdjustment.valueWithCurrencyEffect) + .minus(dividendAdjustment) + .div(previousValueWithCurrencyEffect) + ); + } + + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + cumulativeReturnWithCurrencyEffect + .minus(cumulativeReturnWithoutDividendsWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: cumulativeReturn.minus(1).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + cumulativeReturnWithCurrencyEffect.minus(1).toNumber() + }; + + previousValue = accumulatedValues.totalCurrentValue; + previousValueWithCurrencyEffect = + accumulatedValues.totalCurrentValueWithCurrencyEffect; + } + + return performancePercentagesByDate; } - protected calculatePerformancePercentagesForDateRange(): { - [date: string]: PerformancePercentages; - } { - throw new Error('Method not implemented.'); + protected calculatePerformancePercentagesForDateRange({ + historicalDataItems + }: { + historicalDataItems: HistoricalDataItem[]; + }): { [date: string]: PerformancePercentages } { + const performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + if (historicalDataItems.length === 0) { + return performancePercentagesByDate; + } + + const { + cashFlowsByDate, + dividendAdjustmentsByDate, + performanceAdjustmentsByDate + } = this.getPortfolioAdjustmentsByDate({}); + + let cumulativeReturn = new Big(1); + let cumulativeReturnWithCurrencyEffect = new Big(1); + let cumulativeReturnWithoutDividendsWithCurrencyEffect = new Big(1); + + let previousValue: Big; + let previousValueWithCurrencyEffect: Big; + + for (const [index, historicalDataItem] of historicalDataItems.entries()) { + const currentValue = new Big(historicalDataItem.value ?? 0); + const currentValueWithCurrencyEffect = new Big( + historicalDataItem.valueWithCurrencyEffect ?? 0 + ); + + if (index === 0) { + performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0 + }; + + previousValue = currentValue; + previousValueWithCurrencyEffect = currentValueWithCurrencyEffect; + + continue; + } + + const cashFlow = cashFlowsByDate[historicalDataItem.date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const performanceAdjustment = performanceAdjustmentsByDate[ + historicalDataItem.date + ] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const dividendAdjustment = + dividendAdjustmentsByDate[historicalDataItem.date] ?? new Big(0); + + if (previousValue.gt(0)) { + cumulativeReturn = cumulativeReturn.mul( + currentValue + .minus(cashFlow.value) + .plus(performanceAdjustment.value) + .div(previousValue) + ); + } + + if (previousValueWithCurrencyEffect.gt(0)) { + cumulativeReturnWithCurrencyEffect = + cumulativeReturnWithCurrencyEffect.mul( + currentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(performanceAdjustment.valueWithCurrencyEffect) + .div(previousValueWithCurrencyEffect) + ); + + cumulativeReturnWithoutDividendsWithCurrencyEffect = + cumulativeReturnWithoutDividendsWithCurrencyEffect.mul( + currentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(performanceAdjustment.valueWithCurrencyEffect) + .minus(dividendAdjustment) + .div(previousValueWithCurrencyEffect) + ); + } + + performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: + cumulativeReturnWithCurrencyEffect + .minus(cumulativeReturnWithoutDividendsWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: cumulativeReturn.minus(1).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + cumulativeReturnWithCurrencyEffect.minus(1).toNumber() + }; + + previousValue = currentValue; + previousValueWithCurrencyEffect = currentValueWithCurrencyEffect; + } + + return performancePercentagesByDate; } - protected getHoldingPerformance({}: { + protected getHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; @@ -31,10 +243,386 @@ export class TwrPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { - throw new Error('Method not implemented.'); + const { holdingPerformance, valuation } = + this.getMethodIndependentHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }); + + if (!valuation) { + return holdingPerformance; + } + + const { currentValues, currentValuesWithCurrencyEffect, dateRanges } = + valuation; + + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + const activities = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; + + const firstActivity = activities[0]; + const isCashInBaseCurrency = + firstActivity?.assetProfile.assetSubClass === 'CASH' && + firstActivity.assetProfile.currency === this.getBaseCurrency() && + firstActivity.assetProfile.symbol === this.getBaseCurrency(); + + const cashFlowsByDate: { + [date: string]: { + value: Big; + valueWithCurrencyEffect: Big; + }; + } = {}; + + const currentExchangeRate = exchangeRates?.[chartDates.at(-1) ?? ''] ?? 1; + + for (const activity of activities) { + const exchangeRateAtActivityDate = exchangeRates?.[activity.date] ?? 1; + + if (!isCashInBaseCurrency && ['BUY', 'SELL'].includes(activity.type)) { + const factor = activity.type === 'BUY' ? 1 : -1; + const transactionValue = activity.quantity + .mul(activity.unitPrice) + .mul(factor); + + const currentCashFlow = cashFlowsByDate[activity.date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + cashFlowsByDate[activity.date] = { + value: currentCashFlow.value.plus( + transactionValue.mul(currentExchangeRate) + ), + valueWithCurrencyEffect: currentCashFlow.valueWithCurrencyEffect.plus( + transactionValue.mul(exchangeRateAtActivityDate) + ) + }; + } + } + + const dividendAdjustmentsByDate: { + [date: string]: { + value: Big; + valueWithCurrencyEffect: Big; + }; + } = {}; + + const feeAdjustmentsByDate: { + [date: string]: { + value: Big; + valueWithCurrencyEffect: Big; + }; + } = {}; + + for (const activity of activities) { + const exchangeRateAtActivityDate = exchangeRates?.[activity.date] ?? 1; + + if (activity.type === 'DIVIDEND') { + const dividend = activity.quantity.mul(activity.unitPrice); + + const currentDividendAdjustment = dividendAdjustmentsByDate[ + activity.date + ] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + dividendAdjustmentsByDate[activity.date] = { + value: currentDividendAdjustment.value.plus( + dividend.mul(currentExchangeRate) + ), + valueWithCurrencyEffect: + currentDividendAdjustment.valueWithCurrencyEffect.plus( + dividend.mul(exchangeRateAtActivityDate) + ) + }; + } + + if (!activity.fee.eq(0)) { + const currentFeeAdjustment = feeAdjustmentsByDate[activity.date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + feeAdjustmentsByDate[activity.date] = { + value: currentFeeAdjustment.value.plus( + activity.fee.mul(currentExchangeRate) + ), + valueWithCurrencyEffect: + currentFeeAdjustment.valueWithCurrencyEffect.plus( + activity.fee.mul(exchangeRateAtActivityDate) + ) + }; + } + } + + let cumulativeGrossReturn = new Big(1); + let cumulativeGrossReturnWithCurrencyEffect = new Big(1); + let cumulativeNetReturn = new Big(1); + let cumulativeNetReturnWithCurrencyEffect = new Big(1); + + let previousValue = new Big(0); + let previousValueWithCurrencyEffect = new Big(0); + + const cumulativeNetReturnWithCurrencyEffectByDate: { + [date: string]: Big; + } = {}; + + for (const date of chartDates) { + const currentValue = currentValues[date] ?? new Big(0); + const currentValueWithCurrencyEffect = + currentValuesWithCurrencyEffect[date] ?? new Big(0); + + const cashFlow = cashFlowsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const dividendAdjustment = dividendAdjustmentsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const feeAdjustment = feeAdjustmentsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + if (previousValue.gt(0)) { + cumulativeGrossReturn = cumulativeGrossReturn.mul( + currentValue + .minus(cashFlow.value) + .plus(dividendAdjustment.value) + .div(previousValue) + ); + + cumulativeNetReturn = cumulativeNetReturn.mul( + currentValue + .minus(cashFlow.value) + .plus(dividendAdjustment.value) + .minus(feeAdjustment.value) + .div(previousValue) + ); + } + + if (previousValueWithCurrencyEffect.gt(0)) { + cumulativeGrossReturnWithCurrencyEffect = + cumulativeGrossReturnWithCurrencyEffect.mul( + currentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(dividendAdjustment.valueWithCurrencyEffect) + .div(previousValueWithCurrencyEffect) + ); + + cumulativeNetReturnWithCurrencyEffect = + cumulativeNetReturnWithCurrencyEffect.mul( + currentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(dividendAdjustment.valueWithCurrencyEffect) + .minus(feeAdjustment.valueWithCurrencyEffect) + .div(previousValueWithCurrencyEffect) + ); + } + + cumulativeNetReturnWithCurrencyEffectByDate[date] = + cumulativeNetReturnWithCurrencyEffect; + + previousValue = currentValue; + previousValueWithCurrencyEffect = currentValueWithCurrencyEffect; + } + + const netPerformancePercentageWithCurrencyEffectMap = { + ...holdingPerformance.netPerformancePercentageWithCurrencyEffectMap + }; + + for (const { dateRange, endDateString, startDateString } of dateRanges) { + if (dateRange === 'max') { + netPerformancePercentageWithCurrencyEffectMap[dateRange] = + cumulativeNetReturnWithCurrencyEffect.minus(1); + + continue; + } + + let cumulativeRangeReturnWithCurrencyEffect = new Big(1); + let previousRangeValueWithCurrencyEffect = + currentValuesWithCurrencyEffect[startDateString] ?? new Big(0); + + for (const date of chartDates) { + if (date <= startDateString || date > endDateString) { + continue; + } + + const currentValueWithCurrencyEffect = + currentValuesWithCurrencyEffect[date] ?? new Big(0); + + const cashFlow = cashFlowsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const dividendAdjustment = dividendAdjustmentsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + const feeAdjustment = feeAdjustmentsByDate[date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + if (previousRangeValueWithCurrencyEffect.gt(0)) { + cumulativeRangeReturnWithCurrencyEffect = + cumulativeRangeReturnWithCurrencyEffect.mul( + currentValueWithCurrencyEffect + .minus(cashFlow.valueWithCurrencyEffect) + .plus(dividendAdjustment.valueWithCurrencyEffect) + .minus(feeAdjustment.valueWithCurrencyEffect) + .div(previousRangeValueWithCurrencyEffect) + ); + } + + previousRangeValueWithCurrencyEffect = currentValueWithCurrencyEffect; + } + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = + cumulativeRangeReturnWithCurrencyEffect.minus(1); + } + + return { + ...holdingPerformance, + grossPerformancePercentage: cumulativeGrossReturn.minus(1), + grossPerformancePercentageWithCurrencyEffect: + cumulativeGrossReturnWithCurrencyEffect.minus(1), + netPerformancePercentage: cumulativeNetReturn.minus(1), + netPerformancePercentageWithCurrencyEffectMap + }; } protected getPerformanceCalculationType() { return PerformanceCalculationType.TWR; } + + private getPortfolioAdjustmentsByDate({ + currentExchangeRateDate + }: { + currentExchangeRateDate?: string; + }) { + const cashFlowsByDate: { + [date: string]: { + value: Big; + valueWithCurrencyEffect: Big; + }; + } = {}; + + const performanceAdjustmentsByDate: { + [date: string]: { + value: Big; + valueWithCurrencyEffect: Big; + }; + } = {}; + + const dividendAdjustmentsByDate: { + [date: string]: Big; + } = {}; + + const exchangeRatesByCurrency = this.getExchangeRatesByCurrency(); + + for (const activity of this.activities) { + const exchangeRates = + exchangeRatesByCurrency[ + `${activity.assetProfile.currency}${this.getBaseCurrency()}` + ] ?? {}; + + const lastExchangeRateDate = Object.keys(exchangeRates).at(-1); + const currentExchangeRate = + exchangeRates[currentExchangeRateDate ?? lastExchangeRateDate ?? ''] ?? + 1; + const exchangeRateAtActivityDate = exchangeRates[activity.date] ?? 1; + + const isCashInBaseCurrency = + activity.assetProfile.assetSubClass === 'CASH' && + activity.assetProfile.currency === this.getBaseCurrency() && + activity.assetProfile.symbol === this.getBaseCurrency(); + + if (!isCashInBaseCurrency && ['BUY', 'SELL'].includes(activity.type)) { + const factor = activity.type === 'BUY' ? 1 : -1; + const transactionValue = activity.quantity + .mul(activity.unitPrice) + .mul(factor); + + const currentCashFlow = cashFlowsByDate[activity.date] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + cashFlowsByDate[activity.date] = { + value: currentCashFlow.value.plus( + transactionValue.mul(currentExchangeRate) + ), + valueWithCurrencyEffect: currentCashFlow.valueWithCurrencyEffect.plus( + transactionValue.mul(exchangeRateAtActivityDate) + ) + }; + } + + let performanceAdjustment = activity.fee.mul(currentExchangeRate).mul(-1); + + let performanceAdjustmentWithCurrencyEffect = activity.fee + .mul(exchangeRateAtActivityDate) + .mul(-1); + + if (['DIVIDEND', 'INTEREST'].includes(activity.type)) { + const income = activity.quantity.mul(activity.unitPrice); + + performanceAdjustment = performanceAdjustment.plus( + income.mul(currentExchangeRate) + ); + + performanceAdjustmentWithCurrencyEffect = + performanceAdjustmentWithCurrencyEffect.plus( + income.mul(exchangeRateAtActivityDate) + ); + + if (activity.type === 'DIVIDEND') { + dividendAdjustmentsByDate[activity.date] = ( + dividendAdjustmentsByDate[activity.date] ?? new Big(0) + ).plus(income.mul(exchangeRateAtActivityDate)); + } + } + + if ( + !performanceAdjustment.eq(0) || + !performanceAdjustmentWithCurrencyEffect.eq(0) + ) { + const currentPerformanceAdjustment = performanceAdjustmentsByDate[ + activity.date + ] ?? { + value: new Big(0), + valueWithCurrencyEffect: new Big(0) + }; + + performanceAdjustmentsByDate[activity.date] = { + value: currentPerformanceAdjustment.value.plus(performanceAdjustment), + valueWithCurrencyEffect: + currentPerformanceAdjustment.valueWithCurrencyEffect.plus( + performanceAdjustmentWithCurrencyEffect + ) + }; + } + } + + return { + cashFlowsByDate, + dividendAdjustmentsByDate, + performanceAdjustmentsByDate + }; + } }