diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 537a227b9a..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,11 +548,12 @@ export abstract class PortfolioCalculator { } } - const totalDividendValueByDate = this.getTotalDividendValueByDate({ - chartDates, - exchangeRatesByCurrency, - holdings: positions - }); + const totalDividendValueWithCurrencyEffectByDate = + this.getTotalDividendValueWithCurrencyEffectByDate({ + chartDates, + exchangeRatesByCurrency, + holdings: positions + }); const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); @@ -626,7 +631,8 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) ).add(currentValueWithCurrencyEffect), - totalDividendValue: totalDividendValueByDate[dateString], + totalDividendValueWithCurrencyEffect: + totalDividendValueWithCurrencyEffectByDate[dateString], totalInvestmentValue: ( accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) @@ -651,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( @@ -663,7 +679,7 @@ export abstract class PortfolioCalculator { totalCashValueWithCurrencyEffect, totalCurrentValue, totalCurrentValueWithCurrencyEffect, - totalDividendValue, + totalDividendValueWithCurrencyEffect, totalInvestmentValue, totalInvestmentValueWithCurrencyEffect, totalNetPerformanceValue, @@ -674,7 +690,7 @@ export abstract class PortfolioCalculator { return { ...performancePercentagesByDate[date], date, - dividendInBaseCurrency: totalDividendValue.toNumber(), + dividendInBaseCurrency: totalDividendValueWithCurrencyEffect.toNumber(), investmentValueWithCurrencyEffect: investmentValueWithCurrencyEffect.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(), @@ -692,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, @@ -1363,6 +1370,8 @@ 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; @@ -1390,7 +1399,8 @@ export abstract class PortfolioCalculator { const performancePercentagesByDate = this.calculatePerformancePercentagesForDateRange({ - historicalDataItems: historicalDataItemsOfDateRange + historicalDataItems: historicalDataItemsOfDateRange, + holdings: this.snapshot.positions }); const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => { @@ -1752,7 +1762,7 @@ export abstract class PortfolioCalculator { return chartDateMap; } - private getTotalDividendValueByDate({ + private getTotalDividendValueWithCurrencyEffectByDate({ chartDates, exchangeRatesByCurrency, holdings @@ -1786,10 +1796,11 @@ export abstract class PortfolioCalculator { } ); - const totalDividendValueByDate: { [date: string]: Big } = {}; + const totalDividendValueWithCurrencyEffectByDate: { [date: string]: Big } = + {}; let index = 0; - let totalDividendValue = new Big(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 @@ -1801,23 +1812,25 @@ export abstract class PortfolioCalculator { const { assetProfile, date, quantity, unitPrice } = dividendActivities[index]; - totalDividendValue = totalDividendValue.plus( - quantity - .mul(unitPrice) - .mul( - exchangeRatesByCurrency[ - `${assetProfile.currency}${this.currency}` - ]?.[date] ?? 1 - ) - ); + totalDividendValueWithCurrencyEffect = + totalDividendValueWithCurrencyEffect.plus( + quantity + .mul(unitPrice) + .mul( + exchangeRatesByCurrency[ + `${assetProfile.currency}${this.currency}` + ]?.[date] ?? 1 + ) + ); index++; } - totalDividendValueByDate[chartDate] = totalDividendValue; + totalDividendValueWithCurrencyEffectByDate[chartDate] = + totalDividendValueWithCurrencyEffect; } - return totalDividendValueByDate; + return totalDividendValueWithCurrencyEffectByDate; } @LogPerformance 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 index 7b948be474..ab9c24b4fb 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -182,5 +183,180 @@ describe('PortfolioCalculator', () => { 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.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index abf771aabb..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,16 +186,17 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { { totalAverageInvestmentValue, totalAverageInvestmentValueWithCurrencyEffect, - totalDividendValue, + totalDividendValueWithCurrencyEffect, totalNetPerformanceValue, totalNetPerformanceValueWithCurrencyEffect } ] of Object.entries(accumulatedValuesByDate)) { performancePercentagesByDate[date] = { dividendInPercentageWithCurrencyEffect: + hasDividendWithoutAverageInvestment || totalAverageInvestmentValueWithCurrencyEffect.eq(0) ? 0 - : totalDividendValue + : totalDividendValueWithCurrencyEffect .div(totalAverageInvestmentValueWithCurrencyEffect) .toNumber(), netPerformanceInPercentage: totalAverageInvestmentValue.eq(0) @@ -208,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; @@ -262,6 +276,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { performancePercentagesByDate[historicalDataItem.date] = { dividendInPercentageWithCurrencyEffect: + !hasDividendWithoutAverageInvestment && averageInvestmentValueWithCurrencyEffect > 0 ? historicalDataItem.dividendInBaseCurrency / averageInvestmentValueWithCurrencyEffect @@ -753,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 2f9bbb09a8..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,7 +7,7 @@ export interface AccumulatedValues { totalCashValueWithCurrencyEffect: Big; totalCurrentValue: Big; totalCurrentValueWithCurrencyEffect: Big; - totalDividendValue: 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 2a77455902..45b7477e8b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -581,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 df118115bd..e5866ca3b2 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2136,7 +2136,6 @@ export class PortfolioService { const { currentValueInBaseCurrency, - dividendPercentageWithCurrencyEffect, netPerformance, netPerformancePercentage, netPerformancePercentageWithCurrencyEffect, @@ -2233,7 +2232,6 @@ export class PortfolioService { cash, currentValueInBaseCurrency, dateOfFirstActivity, - dividendPercentageWithCurrencyEffect, excludedAccountsAndActivities, netPerformance, netPerformancePercentage, 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 a2af892ce3..94a0328755 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -286,13 +286,9 @@ class="align-items-center d-flex flex-grow-1 mr-2 text-truncate" > Absolute Dividend Performance - (not included) + @if (user?.subscription?.type === 'Basic') { + + }