diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.spec.ts new file mode 100644 index 0000000000..83d73965b9 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.spec.ts @@ -0,0 +1,1735 @@ +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 { AssetSubClass } from '@prisma/client'; +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({ + assetSubClass, + currency = 'USD', + dataSource = 'YAHOO', + date, + fee = 0, + name = 'MWR Test Asset', + quantity, + symbol = 'MWR_TEST', + type, + unitPrice +}: { + assetSubClass?: AssetSubClass; + 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, + ...(assetSubClass ? { assetSubClass } : {}), + 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 (MWR)', () => { + 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 annualized return over one year', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 1100 : 1000; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + describe('holding performance', () => { + it('calculates the money-weighted return of a holding', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect(position.grossPerformancePercentage.toNumber()).toBeCloseTo( + 0.1, + 12 + ); + + expect( + position.grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.1, 12); + + expect(position.netPerformancePercentage.toNumber()).toBeCloseTo(0.1, 12); + + expect( + position.netPerformancePercentageWithCurrencyEffectMap.max.toNumber() + ).toBeCloseTo(0.1, 12); + }); + + it('separates gross and net holding return when fees are present', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + fee: 10, + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect(position.grossPerformancePercentage.toNumber()).toBeCloseTo( + 0.1, + 12 + ); + + expect( + position.grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.1, 12); + + expect(position.netPerformancePercentage.toNumber()).toBeCloseTo( + 0.0891089108910891, + 12 + ); + + expect( + position.netPerformancePercentageWithCurrencyEffectMap.max.toNumber() + ).toBeCloseTo(0.0891089108910891, 12); + }); + + it('includes dividends in the holding money-weighted return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'DIVIDEND', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect(position.grossPerformancePercentage.toNumber()).toBeCloseTo( + 0.10517029137368816, + 12 + ); + + expect( + position.grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.10517029137368816, 12); + + expect(position.netPerformancePercentage.toNumber()).toBeCloseTo( + 0.10517029137368816, + 12 + ); + + expect( + position.netPerformancePercentageWithCurrencyEffectMap.max.toNumber() + ).toBeCloseTo(0.10517029137368816, 12); + }); + + it('separates holding investment return from currency effect', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + currency: 'EUR', + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 100 : undefined; + } + }); + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + EURUSD: { + '2025-01-01': 1.1, + '2026-01-01': 1.2 + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect(position.grossPerformancePercentage.toNumber()).toBeCloseTo(0, 12); + + expect( + position.grossPerformancePercentageWithCurrencyEffect.toNumber() + ).toBeCloseTo(0.09090909090909083, 12); + + expect(position.netPerformancePercentage.toNumber()).toBeCloseTo(0, 12); + + expect( + position.netPerformancePercentageWithCurrencyEffectMap.max.toNumber() + ).toBeCloseTo(0.09090909090909083, 12); + }); + + it('uses the holding value at the range start as the opening cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 132; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect( + position.netPerformancePercentageWithCurrencyEffectMap['1y']?.toNumber() + ).toBeCloseTo(0.1, 12); + }); + + it('includes holding contributions made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 5, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 1870 / 15; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect( + position.netPerformancePercentageWithCurrencyEffectMap['1y']?.toNumber() + ).toBeCloseTo(0.11763852117676865, 12); + }); + + it('includes holding withdrawals made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 5, + type: 'SELL', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 160; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect( + position.netPerformancePercentageWithCurrencyEffectMap['1y']?.toNumber() + ).toBeCloseTo(0.10480274054180116, 12); + }); + + it('includes holding income and fees made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + fee: 10, + quantity: 1, + type: 'DIVIDEND', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const position = portfolioSnapshot.positions[0]; + + expect( + position.netPerformancePercentageWithCurrencyEffectMap['1y']?.toNumber() + ).toBeCloseTo(0.07789012071165524, 12); + }); + }); + + it('excludes cash in the base currency from money-weighted return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + assetSubClass: AssetSubClass.CASH, + currency: 'USD', + date: '2025-07-01', + name: 'US Dollar', + quantity: 500, + symbol: 'USD', + type: 'BUY', + unitPrice: 1 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect(latestHistoricalDataItem?.netPerformanceInPercentage).toBeCloseTo( + 0.1, + 12 + ); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('includes cash in a foreign currency in money-weighted return', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + assetSubClass: AssetSubClass.CASH, + currency: 'EUR', + date: '2025-01-01', + name: 'Euro', + quantity: 1000, + symbol: 'EUR', + type: 'BUY', + unitPrice: 1 + }) + ]; + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + EURUSD: { + '2025-01-01': 1.1, + '2026-01-01': 1.2 + } + }); + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'EUR' ? 1 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + 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); + }); + + describe('annualization', () => { + it('calculates a 10 percent annualized return over two years', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2027-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2027-01-01'), date) ? 1210 : 1000; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('annualizes a 10 percent return over about six months', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-07-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2025-07-01'), date) ? 1100 : 1000; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.21191297994683578, 12); + }); + + it('weights an intermediate contribution by its timing', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 1155; + } + + return 1000; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.20940902971073883, 12); + }); + }); + + describe('income and fees', () => { + it('includes a dividend as a dated positive cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'DIVIDEND', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.10517029137368816, 12); + + expect( + latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0.10517029137368816, 12); + }); + + it('includes interest as a dated positive cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'INTEREST', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.10517029137368816, 12); + + expect( + latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + + it('includes a transaction fee as a dated negative cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + fee: 10, + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.0891089108910891, 12); + }); + + it('includes a standalone fee activity as a dated negative cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + fee: 10, + quantity: 0, + type: 'FEE', + unitPrice: 0 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.08955812988796497, 12); + }); + }); + + describe('currency effect', () => { + it('separates investment return from currency effect', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + currency: 'EUR', + date: '2025-01-01', + name: 'MWR EUR Test Asset', + quantity: 10, + symbol: 'MWR_EUR_TEST', + type: 'BUY', + unitPrice: 100 + }) + ]; + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + EURUSD: { + '2025-01-01': 1.1, + '2026-01-01': 1.2 + } + }); + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_EUR_TEST' ? 100 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + 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); + }); + + it('combines asset return and currency effect independently', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + currency: 'EUR', + date: '2025-01-01', + name: 'MWR EUR Growth Test Asset', + quantity: 10, + symbol: 'MWR_EUR_GROWTH_TEST', + type: 'BUY', + unitPrice: 100 + }) + ]; + + jest + .spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency') + .mockResolvedValue({ + EURUSD: { + '2025-01-01': 1.1, + '2026-01-01': 1.2 + } + }); + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_EUR_GROWTH_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1); + + expect(latestHistoricalDataItem?.netPerformanceInPercentage).toBeCloseTo( + 0.1, + 12 + ); + + expect( + latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.2, 12); + }); + }); + + describe('date ranges', () => { + it('uses the portfolio value at the range start as the opening cash flow', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 132; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2026-01-01'), + start: parseDate('2025-01-01') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('includes contributions made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 5, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 124.66666666666667; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2026-01-01'), + start: parseDate('2025-01-01') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.11763852117676865, 12); + }); + + it('includes withdrawals made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 5, + type: 'SELL', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (isSameDay(parseDate('2026-01-01'), date)) { + return 160; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2026-01-01'), + start: parseDate('2025-01-01') + }); + + expect( + chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.10480274054180116, 12); + }); + + it('includes income and fees made inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'DIVIDEND', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + fee: 10, + quantity: 0, + type: 'BUY', + unitPrice: 0 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2026-01-01'), + start: parseDate('2025-01-01') + }); + + const latestChartItem = chart.at(-1); + + expect( + latestChartItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.07789012071165524, 12); + + expect( + latestChartItem?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0.08618852115840608, 12); + }); + }); + + describe('date range income classification', () => { + it('does not count interest as a dividend inside the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2024-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 1, + type: 'INTEREST', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + fee: 10, + quantity: 0, + type: 'BUY', + unitPrice: 0 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + if (date >= parseDate('2025-01-01')) { + return 120; + } + + return 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2026-01-01'), + start: parseDate('2025-01-01') + }); + + const latestChartItem = chart.at(-1); + + expect( + latestChartItem?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.07789012071165524, 12); + + expect( + latestChartItem?.dividendInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + }); + + describe('solver edge cases', () => { + it('calculates a return close to minus one hundred percent', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 1000 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 1 : 1000; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(-0.999, 12); + }); + }); + + describe('solver multiple roots', () => { + it('uses the XIRR root reached from a zero initial estimate', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2027-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2026-01-01', + quantity: 1, + type: 'SELL', + unitPrice: 230 + }), + createActivity({ + date: '2027-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 132 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 0 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + }); + + describe('solver no root', () => { + it('returns zero when no valid XIRR root exists', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2027-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2026-01-01', + quantity: 1, + type: 'SELL', + unitPrice: 50 + }), + createActivity({ + date: '2027-01-01', + quantity: 1, + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 0 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0, 12); + }); + }); + + describe('cash flows', () => { + it('accounts for a partial withdrawal at its transaction date', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + quantity: 5, + type: 'SELL', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (symbol !== 'MWR_TEST') { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 120 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.132325784874732, 12); + }); + + it('nets a same-day sell and buy as an internal portfolio transfer', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + name: 'MWR Test Asset A', + quantity: 10, + symbol: 'MWR_TEST_A', + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + name: 'MWR Test Asset A', + quantity: 5, + symbol: 'MWR_TEST_A', + type: 'SELL', + unitPrice: 100 + }), + createActivity({ + date: '2025-07-01', + name: 'MWR Test Asset B', + quantity: 5, + symbol: 'MWR_TEST_B', + type: 'BUY', + unitPrice: 100 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ date, symbol }) => { + if (!['MWR_TEST_A', 'MWR_TEST_B'].includes(symbol)) { + return undefined; + } + + return isSameDay(parseDate('2026-01-01'), date) ? 110 : 100; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + + it('calculates the return of a fully closed position from the sell proceeds', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2026-01-01').getTime()); + + const activities: Activity[] = [ + createActivity({ + date: '2025-01-01', + quantity: 10, + type: 'BUY', + unitPrice: 100 + }), + createActivity({ + date: '2026-01-01', + quantity: 10, + type: 'SELL', + unitPrice: 110 + }) + ]; + + mockMarketPrices({ + currentRateService, + getMarketPrice: ({ symbol }) => { + return symbol === 'MWR_TEST' ? 110 : undefined; + } + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.MWR, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect( + portfolioSnapshot.historicalData.at(-1) + ?.netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1, 12); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index f83b175128..318b290447 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -1,28 +1,197 @@ 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 { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.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, + parseDate +} 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 { AssetSubClass } from '@prisma/client'; +import { Big } from 'big.js'; +import { differenceInCalendarDays } from 'date-fns'; + +interface DatedCashFlow { + date: string; + value: Big; +} + +/** + * Money-Weighted Rate of Return (MWR): the annualized internal rate of return + * of the dated investor cash flows and the terminal portfolio value. + */ export class MwrPortfolioCalculator 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 performancePercentagesByDate: { + [date: string]: PerformancePercentages; + } = {}; + + for (const [date, accumulatedValues] of Object.entries( + accumulatedValuesByDate + )) { + const netPerformanceInPercentageWithCurrencyEffect = + this.getMoneyWeightedReturn({ + date, + terminalValue: accumulatedValues.totalCurrentValueWithCurrencyEffect, + withCurrencyEffect: true + }); + + const netPerformanceInPercentageWithoutDividendsWithCurrencyEffect = + this.getMoneyWeightedReturn({ + date, + includeDividends: false, + terminalValue: accumulatedValues.totalCurrentValueWithCurrencyEffect, + withCurrencyEffect: true + }); + + performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect - + netPerformanceInPercentageWithoutDividendsWithCurrencyEffect, + netPerformanceInPercentage: this.getMoneyWeightedReturn({ + date, + terminalValue: accumulatedValues.totalCurrentValue, + withCurrencyEffect: false + }), + netPerformanceInPercentageWithCurrencyEffect + }; + } + + 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; + } = {}; + + const firstHistoricalDataItem = historicalDataItems[0]; + + if (!firstHistoricalDataItem) { + return performancePercentagesByDate; + } + + const openingDate = firstHistoricalDataItem.date; + const openingValue = new Big(firstHistoricalDataItem.value ?? 0); + const openingValueWithCurrencyEffect = new Big( + firstHistoricalDataItem.valueWithCurrencyEffect ?? 0 + ); + + for (const historicalDataItem of historicalDataItems) { + const cashFlows = [ + { + date: openingDate, + value: openingValue.mul(-1) + }, + ...this.getActivityCashFlows({ + endDate: historicalDataItem.date, + startDate: openingDate, + withCurrencyEffect: false + }), + { + date: historicalDataItem.date, + value: new Big(historicalDataItem.value ?? 0) + } + ]; + + const cashFlowsWithCurrencyEffect = [ + { + date: openingDate, + value: openingValueWithCurrencyEffect.mul(-1) + }, + ...this.getActivityCashFlows({ + endDate: historicalDataItem.date, + startDate: openingDate, + withCurrencyEffect: true + }), + { + date: historicalDataItem.date, + value: new Big(historicalDataItem.valueWithCurrencyEffect ?? 0) + } + ]; + + const cashFlowsWithoutDividendsWithCurrencyEffect = [ + { + date: openingDate, + value: openingValueWithCurrencyEffect.mul(-1) + }, + ...this.getActivityCashFlows({ + endDate: historicalDataItem.date, + includeDividends: false, + startDate: openingDate, + withCurrencyEffect: true + }), + { + date: historicalDataItem.date, + value: new Big(historicalDataItem.valueWithCurrencyEffect ?? 0) + } + ]; + + const netPerformanceInPercentageWithCurrencyEffect = this.calculateXirr( + cashFlowsWithCurrencyEffect + ); + + const netPerformanceInPercentageWithoutDividendsWithCurrencyEffect = + this.calculateXirr(cashFlowsWithoutDividendsWithCurrencyEffect); + + performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect - + netPerformanceInPercentageWithoutDividendsWithCurrencyEffect, + netPerformanceInPercentage: this.calculateXirr(cashFlows), + netPerformanceInPercentageWithCurrencyEffect + }; + } + + return performancePercentagesByDate; } - protected getHoldingPerformance({}: { + protected getHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; @@ -31,10 +200,411 @@ export class MwrPortfolioCalculator 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 assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + const activities = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; + + const endDate = chartDates.at(-1); + + if (!endDate) { + return holdingPerformance; + } + + const terminalValue = valuation.currentValues[endDate] ?? new Big(0); + const terminalValueWithCurrencyEffect = + valuation.currentValuesWithCurrencyEffect[endDate] ?? new Big(0); + + const grossPerformancePercentage = this.calculateXirr([ + ...this.getActivityCashFlows({ + activities, + endDate, + includeFees: false, + withCurrencyEffect: false + }), + { + date: endDate, + value: terminalValue + } + ]); + + const grossPerformancePercentageWithCurrencyEffect = this.calculateXirr([ + ...this.getActivityCashFlows({ + activities, + endDate, + includeFees: false, + withCurrencyEffect: true + }), + { + date: endDate, + value: terminalValueWithCurrencyEffect + } + ]); + + const netPerformancePercentage = this.calculateXirr([ + ...this.getActivityCashFlows({ + activities, + endDate, + includeFees: true, + withCurrencyEffect: false + }), + { + date: endDate, + value: terminalValue + } + ]); + + const netPerformancePercentageWithCurrencyEffect = this.calculateXirr([ + ...this.getActivityCashFlows({ + activities, + endDate, + includeFees: true, + withCurrencyEffect: true + }), + { + date: endDate, + value: terminalValueWithCurrencyEffect + } + ]); + + const netPerformancePercentageWithCurrencyEffectMap: HoldingPerformance['netPerformancePercentageWithCurrencyEffectMap'] = + { + ...holdingPerformance.netPerformancePercentageWithCurrencyEffectMap, + max: new Big(netPerformancePercentageWithCurrencyEffect) + }; + + for (const { + dateRange, + endDateString: rangeEndDateString, + startDateString: rangeStartDateString + } of valuation.dateRanges) { + if (dateRange === 'max') { + continue; + } + + const openingValueWithCurrencyEffect = + valuation.currentValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0); + + const terminalValueOfRangeWithCurrencyEffect = + valuation.currentValuesWithCurrencyEffect[rangeEndDateString] ?? + new Big(0); + + const rangeReturnWithCurrencyEffect = this.calculateXirr([ + { + date: rangeStartDateString, + value: openingValueWithCurrencyEffect.mul(-1) + }, + ...this.getActivityCashFlows({ + activities, + endDate: rangeEndDateString, + includeFees: true, + startDate: rangeStartDateString, + withCurrencyEffect: true + }), + { + date: rangeEndDateString, + value: terminalValueOfRangeWithCurrencyEffect + } + ]); + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = new Big( + rangeReturnWithCurrencyEffect + ); + } + + return { + ...holdingPerformance, + grossPerformancePercentage: new Big(grossPerformancePercentage), + grossPerformancePercentageWithCurrencyEffect: new Big( + grossPerformancePercentageWithCurrencyEffect + ), + netPerformancePercentage: new Big(netPerformancePercentage), + netPerformancePercentageWithCurrencyEffectMap + }; } protected getPerformanceCalculationType() { return PerformanceCalculationType.MWR; } + + private calculateXirr(cashFlows: DatedCashFlow[]): number { + const cashFlowsByDate = new Map(); + + for (const { date, value } of cashFlows) { + cashFlowsByDate.set( + date, + (cashFlowsByDate.get(date) ?? new Big(0)).plus(value) + ); + } + + const aggregatedCashFlows = Array.from(cashFlowsByDate.entries()) + .map(([date, value]) => ({ date, value })) + .filter(({ value }) => !value.eq(0)) + .sort((a, b) => a.date.localeCompare(b.date)); + + if (aggregatedCashFlows.length < 2) { + return 0; + } + + if ( + !aggregatedCashFlows.some(({ value }) => value.lt(0)) || + !aggregatedCashFlows.some(({ value }) => value.gt(0)) + ) { + return 0; + } + + const firstDate = parseDate(aggregatedCashFlows[0].date); + const lastDate = parseDate(aggregatedCashFlows.at(-1).date); + + if (differenceInCalendarDays(lastDate, firstDate) === 0) { + return 0; + } + + const scale = aggregatedCashFlows.reduce((maximum, { value }) => { + return value.abs().gt(maximum) ? value.abs() : maximum; + }, new Big(0)); + + if (scale.eq(0)) { + return 0; + } + + const normalizedCashFlows = aggregatedCashFlows.map(({ date, value }) => ({ + date, + value: value.div(scale).toNumber() + })); + + const calculateXnpv = (rate: number) => { + if (rate <= -1) { + return Number.POSITIVE_INFINITY; + } + + return normalizedCashFlows.reduce((xnpv, cashFlow) => { + const days = differenceInCalendarDays( + parseDate(cashFlow.date), + firstDate + ); + + return xnpv + cashFlow.value / Math.pow(1 + rate, days / 365); + }, 0); + }; + + // Start Newton's method at zero. In common multiple-IRR cases this + // deterministically favors the root reached from that initial estimate. + let newtonRate = 0; + + for (let iteration = 0; iteration < 50; iteration += 1) { + const xnpv = calculateXnpv(newtonRate); + + if (Math.abs(xnpv) < 1e-14) { + return newtonRate; + } + + const derivative = normalizedCashFlows.reduce((value, cashFlow) => { + const days = differenceInCalendarDays( + parseDate(cashFlow.date), + firstDate + ); + const exponent = days / 365; + + if (exponent === 0) { + return value; + } + + return ( + value - + (exponent * cashFlow.value) / Math.pow(1 + newtonRate, exponent + 1) + ); + }, 0); + + if (!Number.isFinite(derivative) || Math.abs(derivative) < 1e-14) { + break; + } + + const nextRate = newtonRate - xnpv / derivative; + + if (!Number.isFinite(nextRate) || nextRate <= -1) { + break; + } + + if (Math.abs(nextRate - newtonRate) < 1e-14) { + return nextRate; + } + + newtonRate = nextRate; + } + + let lowerRate = -0.999999999; + let upperRate = 1; + + let lowerValue = calculateXnpv(lowerRate); + let upperValue = calculateXnpv(upperRate); + + if (Math.abs(lowerValue) < 1e-14) { + return lowerRate; + } + + if (Math.abs(upperValue) < 1e-14) { + return upperRate; + } + + while ( + Math.sign(lowerValue) === Math.sign(upperValue) && + upperRate < 1_000_000 + ) { + upperRate = upperRate * 2 + 1; + upperValue = calculateXnpv(upperRate); + } + + if ( + !Number.isFinite(lowerValue) || + !Number.isFinite(upperValue) || + Math.sign(lowerValue) === Math.sign(upperValue) + ) { + return 0; + } + + for (let iteration = 0; iteration < 200; iteration += 1) { + const middleRate = (lowerRate + upperRate) / 2; + const middleValue = calculateXnpv(middleRate); + + if (Math.abs(middleValue) < 1e-14) { + return middleRate; + } + + if (Math.sign(lowerValue) === Math.sign(middleValue)) { + lowerRate = middleRate; + lowerValue = middleValue; + } else { + upperRate = middleRate; + upperValue = middleValue; + } + + if (Math.abs(upperRate - lowerRate) < 1e-14) { + return (lowerRate + upperRate) / 2; + } + } + + return (lowerRate + upperRate) / 2; + } + + private getActivityCashFlows({ + activities = this.activities, + endDate, + includeDividends = true, + includeFees = true, + startDate, + withCurrencyEffect + }: { + activities?: PortfolioCalculatorActivity[]; + endDate: string; + includeDividends?: boolean; + includeFees?: boolean; + startDate?: string; + withCurrencyEffect: boolean; + }): DatedCashFlow[] { + const cashFlows: DatedCashFlow[] = []; + const exchangeRatesByCurrency = this.getExchangeRatesByCurrency(); + + for (const activity of activities) { + const isCashInBaseCurrency = + activity.assetProfile.assetSubClass === AssetSubClass.CASH && + activity.assetProfile.currency === this.getBaseCurrency() && + activity.assetProfile.symbol === this.getBaseCurrency(); + + if ( + isCashInBaseCurrency || + activity.date > endDate || + (startDate !== undefined && activity.date <= startDate) + ) { + continue; + } + + const currencyPair = + activity.assetProfile.currency + this.getBaseCurrency(); + + const exchangeRates = exchangeRatesByCurrency[currencyPair]; + + const exchangeRate = withCurrencyEffect + ? (exchangeRates?.[activity.date] ?? 1) + : (exchangeRates?.[endDate] ?? 1); + + if (includeFees && !activity.fee.eq(0)) { + cashFlows.push({ + date: activity.date, + value: activity.fee.mul(exchangeRate).mul(-1) + }); + } + + if (['BUY', 'SELL'].includes(activity.type)) { + const transactionValue = activity.quantity + .mul(activity.unitPrice) + .mul(exchangeRate); + + cashFlows.push({ + date: activity.date, + value: + activity.type === 'BUY' + ? transactionValue.mul(-1) + : transactionValue + }); + + continue; + } + + if ( + activity.type === 'INTEREST' || + (includeDividends && activity.type === 'DIVIDEND') + ) { + cashFlows.push({ + date: activity.date, + value: activity.quantity.mul(activity.unitPrice).mul(exchangeRate) + }); + } + } + + return cashFlows; + } + + private getMoneyWeightedReturn({ + date, + includeDividends = true, + terminalValue, + withCurrencyEffect + }: { + date: string; + includeDividends?: boolean; + terminalValue: Big; + withCurrencyEffect: boolean; + }): number { + return this.calculateXirr([ + ...this.getActivityCashFlows({ + endDate: date, + includeDividends, + withCurrencyEffect + }), + { + date, + value: terminalValue + } + ]); + } } 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[] ) {