diff --git a/CHANGELOG.md b/CHANGELOG.md index f5431bf8ca..c199d06c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the dividend performance to the analysis page (experimental) + ### Changed - Simplified the portfolio summary by hiding the currency on mobile diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d9efa9f86c..d868b65b3f 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -223,15 +223,19 @@ export abstract class PortfolioCalculator { ): PortfolioSnapshot; protected abstract calculatePerformancePercentages({ - accumulatedValuesByDate + accumulatedValuesByDate, + holdings }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages }; protected abstract calculatePerformancePercentagesForDateRange({ - historicalDataItems + historicalDataItems, + holdings }: { historicalDataItems: HistoricalDataItem[]; + holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages }; @LogPerformance @@ -544,6 +548,13 @@ export abstract class PortfolioCalculator { } } + const totalDividendValueWithCurrencyEffectByDate = + this.getTotalDividendValueWithCurrencyEffectByDate({ + chartDates, + exchangeRatesByCurrency, + holdings: positions + }); + const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); for (const dateString of chartDates) { @@ -620,6 +631,8 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) ).add(currentValueWithCurrencyEffect), + totalDividendValueWithCurrencyEffect: + totalDividendValueWithCurrencyEffectByDate[dateString], totalInvestmentValue: ( accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) @@ -644,8 +657,18 @@ export abstract class PortfolioCalculator { } } + const positionsIncludedInHoldings = positions + .filter(({ includeInHoldings }) => { + return includeInHoldings; + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ includeInHoldings, includeInPerformance, ...rest }) => { + return rest; + }); + const performancePercentagesByDate = this.calculatePerformancePercentages({ - accumulatedValuesByDate + accumulatedValuesByDate, + holdings: positionsIncludedInHoldings }); const historicalData: HistoricalDataItem[] = Object.entries( @@ -656,6 +679,7 @@ export abstract class PortfolioCalculator { totalCashValueWithCurrencyEffect, totalCurrentValue, totalCurrentValueWithCurrencyEffect, + totalDividendValueWithCurrencyEffect, totalInvestmentValue, totalInvestmentValueWithCurrencyEffect, totalNetPerformanceValue, @@ -666,6 +690,7 @@ export abstract class PortfolioCalculator { return { ...performancePercentagesByDate[date], date, + dividendInBaseCurrency: totalDividendValueWithCurrencyEffect.toNumber(), investmentValueWithCurrencyEffect: investmentValueWithCurrencyEffect.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(), @@ -683,15 +708,6 @@ export abstract class PortfolioCalculator { const overall = this.calculateOverallPerformance(positions); - const positionsIncludedInHoldings = positions - .filter(({ includeInHoldings }) => { - return includeInHoldings; - }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(({ includeInHoldings, includeInPerformance, ...rest }) => { - return rest; - }); - return { ...overall, errors, @@ -1343,6 +1359,7 @@ export abstract class PortfolioCalculator { const historicalDataItemsOfDateRange: HistoricalDataItem[] = []; + let dividendInBaseCurrencyAtStartDate: number; let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; @@ -1353,6 +1370,11 @@ export abstract class PortfolioCalculator { // Take the values at the start date from the first day of the date // range if (historicalDataItemsOfDateRange.length === 0) { + // TODO: Remove the fallback to 0 with the next release, when each + // cached portfolio snapshot contains the dividend + dividendInBaseCurrencyAtStartDate = + historicalDataItem.dividendInBaseCurrency ?? 0; + netPerformanceAtStartDate = historicalDataItem.netPerformance; netPerformanceWithCurrencyEffectAtStartDate = @@ -1361,6 +1383,11 @@ export abstract class PortfolioCalculator { historicalDataItemsOfDateRange.push({ ...historicalDataItem, + // TODO: Remove the fallback to 0 with the next release, when each + // cached portfolio snapshot contains the dividend + dividendInBaseCurrency: + (historicalDataItem.dividendInBaseCurrency ?? 0) - + dividendInBaseCurrencyAtStartDate, netPerformance: historicalDataItem.netPerformance - netPerformanceAtStartDate, netPerformanceWithCurrencyEffect: @@ -1372,7 +1399,8 @@ export abstract class PortfolioCalculator { const performancePercentagesByDate = this.calculatePerformancePercentagesForDateRange({ - historicalDataItems: historicalDataItemsOfDateRange + historicalDataItems: historicalDataItemsOfDateRange, + holdings: this.snapshot.positions }); const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => { @@ -1734,6 +1762,77 @@ export abstract class PortfolioCalculator { return chartDateMap; } + private getTotalDividendValueWithCurrencyEffectByDate({ + chartDates, + exchangeRatesByCurrency, + holdings + }: { + chartDates: string[]; + exchangeRatesByCurrency: { + [currencyPair: string]: { [dateString: string]: number }; + }; + holdings: PortfolioCalculatorHolding[]; + }): { [date: string]: Big } { + // Take the dividend from the same holdings as the portfolio summary, so + // that the response shows one dividend only + const assetProfileIdentifiersIncludedInHoldings = new Set( + holdings + .filter(({ includeInHoldings }) => { + return includeInHoldings; + }) + .map(({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ dataSource, symbol }); + }) + ); + + const dividendActivities = this.activities.filter( + ({ assetProfile, type }) => { + return ( + type === 'DIVIDEND' && + assetProfileIdentifiersIncludedInHoldings.has( + getAssetProfileIdentifier(assetProfile) + ) + ); + } + ); + + const totalDividendValueWithCurrencyEffectByDate: { [date: string]: Big } = + {}; + + let index = 0; + let totalDividendValueWithCurrencyEffect = new Big(0); + + // The activities and the chart dates are sorted by date, so one pass over + // both gives the dividends received up to each chart date + for (const chartDate of chartDates) { + while ( + index < dividendActivities.length && + dividendActivities[index].date <= chartDate + ) { + const { assetProfile, date, quantity, unitPrice } = + dividendActivities[index]; + + totalDividendValueWithCurrencyEffect = + totalDividendValueWithCurrencyEffect.plus( + quantity + .mul(unitPrice) + .mul( + exchangeRatesByCurrency[ + `${assetProfile.currency}${this.currency}` + ]?.[date] ?? 1 + ) + ); + + index++; + } + + totalDividendValueWithCurrencyEffectByDate[chartDate] = + totalDividendValueWithCurrencyEffect; + } + + return totalDividendValueWithCurrencyEffectByDate; + } + @LogPerformance private async initialize(attempt = 1) { const startTimeTotal = performance.now(); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index aadf2e23a0..ed8841b167 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -142,6 +142,8 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2021-12-11', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, @@ -160,6 +162,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData[1]).toEqual({ date: '2021-12-12', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 44558.42, netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 @@ -179,6 +183,8 @@ describe('PortfolioCalculator', () => { ] ).toEqual({ date: '2022-01-14', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: -1463.18, netPerformanceInPercentage: -0.032837340282712, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index fe86d9d457..e0cfeb8227 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -142,6 +142,8 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2021-12-11', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, @@ -160,6 +162,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData[1]).toEqual({ date: '2021-12-12', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 44558.42, netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 @@ -179,6 +183,8 @@ describe('PortfolioCalculator', () => { ] ).toEqual({ date: '2022-01-14', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: -1463.18, netPerformanceInPercentage: -0.032837340282712, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 23c806483c..b98aa9e5f2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -321,6 +321,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ date: '2025-01-01', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, @@ -459,6 +461,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ date: '2025-01-01', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, @@ -601,6 +605,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ date: '2025-01-01', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts new file mode 100644 index 0000000000..ab9c24b4fb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts @@ -0,0 +1,362 @@ +import { + activityDummyData, + assetProfileDummyData, + getPerformanceByDateRange, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with GOOGL fee and dividend', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + }, + { + // The first activity of this asset profile is a fee, which is not an + // investment activity. Thus the holding is not included in the + // holdings of the portfolio summary, and its dividend must not be + // included in the chart + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 1, + quantity: 0, + type: 'FEE', + unitPriceInAssetProfileCurrency: 0 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + date: new Date('2023-07-10'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 5 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); + + expect( + portfolioSnapshot.positions.map(({ symbol }) => { + return symbol; + }) + ).toEqual(['MSFT']); + + // The chart and the portfolio summary must show the same dividend + expect(dividendInBaseCurrency).toEqual(new Big('0.62')); + + expect( + portfolioSnapshot.historicalData.at(-1).dividendInBaseCurrency + ).toEqual(0.62); + }); + + it('with GOOGL dividend without investment', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + }, + { + // The holding has a dividend, but no average investment. Thus the + // dividend percentage stays 0, like the dividend yield + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + date: new Date('2023-07-10'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 5 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['max'] + }); + + expect(portfolioSnapshot).toMatchObject({ + dividendYieldPercentWithCurrencyEffect: new Big(0) + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + dividendInBaseCurrency: 5.62, + dividendInPercentageWithCurrencyEffect: 0 + }); + + expect(performanceByDateRange).toMatchObject({ + max: { + dividendInBaseCurrency: 5.62, + dividendInPercentageWithCurrencyEffect: 0 + } + }); + }); + + it('with MSFT dividend before and in the date range', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2023-07-10'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.68 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['1d', 'max', 'ytd'] + }); + + // The dividend before the date range is subtracted, so that the date + // range shows the dividend in the date range only + expect(performanceByDateRange).toMatchObject({ + '1d': { + dividendInBaseCurrency: 0.68, + dividendInPercentageWithCurrencyEffect: 0.002016487752802325 + }, + max: { + dividendInBaseCurrency: 1.3, + dividendInPercentageWithCurrencyEffect: 0.004353941992095899 + }, + ytd: { + dividendInBaseCurrency: 0.68, + dividendInPercentageWithCurrencyEffect: 0.002002886512915673 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 18dc5ab246..2c6ff79b6d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -198,6 +198,8 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ '1d': { date: '2023-07-10', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, netPerformance: -5.390000000000001, netPerformanceInPercentage: -0.015983630864124312, netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124312, @@ -207,6 +209,8 @@ describe('PortfolioCalculator', () => { }, max: { date: '2023-07-10', + dividendInBaseCurrency: 0.62, + dividendInPercentageWithCurrencyEffect: 0.002076495411614967, netPerformance: 14.25, netPerformanceInPercentage: 0.04772590260566659, netPerformanceInPercentageWithCurrencyEffect: 0.04772590260566659, @@ -216,6 +220,8 @@ describe('PortfolioCalculator', () => { }, ytd: { date: '2023-07-10', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, netPerformance: -7.68, netPerformanceInPercentage: -0.022620835910577012, netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577012, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index feb9a3c20e..46e1b14aa4 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -139,6 +139,8 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2022-03-06', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, @@ -158,6 +160,8 @@ describe('PortfolioCalculator', () => { */ expect(portfolioSnapshot.historicalData[1]).toEqual({ date: '2022-03-07', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 151.6, netPerformance: 0, // 2 * (75.8 - 75.8) = 0 netPerformanceInPercentage: 0, // 0 ÷ 151.6 = 0 @@ -177,6 +181,8 @@ describe('PortfolioCalculator', () => { ] ).toEqual({ date: '2022-04-11', + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0, netPerformance: 19.86, netPerformanceInPercentage: 0.13100263852242744, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 39a1b4188b..2b7b4e6dce 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -17,7 +17,10 @@ import { AssetProfileIdentifier, HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { + PortfolioSnapshot, + PortfolioSnapshotHolding +} from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -165,10 +168,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentages({ - accumulatedValuesByDate + accumulatedValuesByDate, + holdings }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { + const hasDividendWithoutAverageInvestment = + this.hasDividendWithoutAverageInvestment(holdings); + const performancePercentagesByDate: { [date: string]: PerformancePercentages; } = {}; @@ -178,11 +186,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { { totalAverageInvestmentValue, totalAverageInvestmentValueWithCurrencyEffect, + totalDividendValueWithCurrencyEffect, totalNetPerformanceValue, totalNetPerformanceValueWithCurrencyEffect } ] of Object.entries(accumulatedValuesByDate)) { performancePercentagesByDate[date] = { + dividendInPercentageWithCurrencyEffect: + hasDividendWithoutAverageInvestment || + totalAverageInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalDividendValueWithCurrencyEffect + .div(totalAverageInvestmentValueWithCurrencyEffect) + .toNumber(), netPerformanceInPercentage: totalAverageInvestmentValue.eq(0) ? 0 : totalNetPerformanceValue @@ -201,10 +217,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentagesForDateRange({ - historicalDataItems + historicalDataItems, + holdings }: { historicalDataItems: HistoricalDataItem[]; + holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { + const hasDividendWithoutAverageInvestment = + this.hasDividendWithoutAverageInvestment(holdings); + const averageInvestmentValues: number[] = []; const averageInvestmentValuesWithCurrencyEffect: number[] = []; let grossPerformanceAtStartDate: number; @@ -254,6 +275,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { : 0; performancePercentagesByDate[historicalDataItem.date] = { + dividendInPercentageWithCurrencyEffect: + !hasDividendWithoutAverageInvestment && + averageInvestmentValueWithCurrencyEffect > 0 + ? historicalDataItem.dividendInBaseCurrency / + averageInvestmentValueWithCurrencyEffect + : 0, netPerformanceInPercentage: averageInvestmentValue > 0 ? historicalDataItem.netPerformance / averageInvestmentValue @@ -741,4 +768,21 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { protected getPerformanceCalculationType() { return PerformanceCalculationType.ROAI; } + + private hasDividendWithoutAverageInvestment( + holdings: PortfolioSnapshotHolding[] + ) { + // A holding with a dividend only (without a buy activity), and a holding + // without a market price, gives a dividend but no average investment. + // Such a holding makes the dividend percentage too high. Therefore the + // dividend percentage stays 0 in this case, like the dividend yield. + return holdings.some( + ({ averageInvestmentWithCurrencyEffect, dividendInBaseCurrency }) => { + return ( + !dividendInBaseCurrency.eq(0) && + averageInvestmentWithCurrencyEffect.eq(0) + ); + } + ); + } } diff --git a/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts index 919dccc227..3810cbdeca 100644 --- a/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts @@ -7,6 +7,7 @@ export interface AccumulatedValues { totalCashValueWithCurrencyEffect: Big; totalCurrentValue: Big; totalCurrentValueWithCurrencyEffect: Big; + totalDividendValueWithCurrencyEffect: Big; totalInvestmentValue: Big; totalInvestmentValueWithCurrencyEffect: Big; totalNetPerformanceValue: Big; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index a65186100a..45b7477e8b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -565,6 +565,7 @@ export class PortfolioController { [ 'currentNetWorth', 'currentValueInBaseCurrency', + 'dividendInBaseCurrency', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', 'netPerformance', @@ -580,12 +581,16 @@ export class PortfolioController { ) { performanceInformation.chart = performanceInformation.chart.map( (item) => { - return nullifyValuesInObject(item, ['totalInvestment', 'value']); + return nullifyValuesInObject(item, [ + 'dividendInBaseCurrency', + 'totalInvestment', + 'value' + ]); } ); performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance, - ['netPerformance'] + ['dividendInBaseCurrency', 'netPerformance'] ); } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 90de5ddc38..e5866ca3b2 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1221,6 +1221,8 @@ export class PortfolioService { performance: { currentNetWorth: 0, currentValueInBaseCurrency: 0, + dividendInBaseCurrency: 0, + dividendPercentageWithCurrencyEffect: 0, netPerformance: 0, netPerformancePercentage: 0, netPerformancePercentageWithCurrencyEffect: 0, @@ -1252,6 +1254,8 @@ export class PortfolioService { }); const { + dividendInBaseCurrency, + dividendInPercentageWithCurrencyEffect, netPerformance, netPerformanceInPercentage, netPerformanceInPercentageWithCurrencyEffect, @@ -1261,6 +1265,8 @@ export class PortfolioService { totalInvestmentValueWithCurrencyEffect, valueWithCurrencyEffect } = chart?.at(-1) ?? { + dividendInBaseCurrency: 0, + dividendInPercentageWithCurrencyEffect: 0, netPerformance: 0, netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, @@ -1276,12 +1282,15 @@ export class PortfolioService { hasErrors, dateOfFirstActivity: parseDate(historicalData[0]?.date), performance: { + dividendInBaseCurrency, netPerformance, netPerformanceWithCurrencyEffect, totalInvestment, totalInvestmentValueWithCurrencyEffect, currentNetWorth: netWorth, currentValueInBaseCurrency: valueWithCurrencyEffect, + dividendPercentageWithCurrencyEffect: + dividendInPercentageWithCurrencyEffect, netPerformancePercentage: netPerformanceInPercentage, netPerformancePercentageWithCurrencyEffect: netPerformanceInPercentageWithCurrencyEffect diff --git a/apps/api/src/app/portfolio/types/performance-percentages.type.ts b/apps/api/src/app/portfolio/types/performance-percentages.type.ts index 99521e516e..590e25895a 100644 --- a/apps/api/src/app/portfolio/types/performance-percentages.type.ts +++ b/apps/api/src/app/portfolio/types/performance-percentages.type.ts @@ -3,6 +3,7 @@ import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; export type PerformancePercentages = Required< Pick< HistoricalDataItem, + | 'dividendInPercentageWithCurrencyEffect' | 'netPerformanceInPercentage' | 'netPerformanceInPercentageWithCurrencyEffect' > diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index abf2de00ee..94a0328755 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -279,6 +279,46 @@ /> + @if (user?.settings?.isExperimentalFeatures) { +

+
+
+ Absolute Dividend Performance + @if (user?.subscription?.type === 'Basic') { + + } +
+
+ +
+
+
+
+ Dividend Performance +
+
+ +
+
+ } diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index adb5b7e655..a733837d52 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -1,6 +1,8 @@ export interface HistoricalDataItem { averagePrice?: number; date: string; + dividendInBaseCurrency?: number; + dividendInPercentageWithCurrencyEffect?: number; grossPerformancePercent?: number; investmentValueWithCurrencyEffect?: number; marketPrice?: number; diff --git a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts index 0698004d53..da0bb04f40 100644 --- a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts @@ -2,6 +2,8 @@ export interface PortfolioPerformance { annualizedPerformancePercent?: number; currentNetWorth?: number; currentValueInBaseCurrency: number; + dividendInBaseCurrency: number; + dividendPercentageWithCurrencyEffect?: number; netPerformance: number; netPerformancePercentage: number; netPerformancePercentageWithCurrencyEffect: number; diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 4e6402d162..d9bd435b1b 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -7,7 +7,6 @@ export interface PortfolioSummary extends PortfolioPerformance { annualizedPerformancePercentWithCurrencyEffect: number; cash: number; dateOfFirstActivity?: Date; - dividendInBaseCurrency: number; dividendYieldPercent: number; dividendYieldPercentWithCurrencyEffect: number; emergencyFund: {