From fbadb1eb1f3a8a186faa2060c124db5e1cb09697 Mon Sep 17 00:00:00 2001
From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
Date: Sun, 27 Sep 2026 17:19:04 +0200
Subject: [PATCH] Add dividend performance
---
.../calculator/portfolio-calculator.ts | 83 +++++----
...-calculator-googl-fee-and-dividend.spec.ts | 176 ++++++++++++++++++
.../calculator/roai/portfolio-calculator.ts | 42 ++++-
.../accumulated-values.interface.ts | 2 +-
.../src/app/portfolio/portfolio.controller.ts | 8 +-
.../src/app/portfolio/portfolio.service.ts | 2 -
.../portfolio/analysis/analysis-page.html | 10 +-
.../portfolio-performance.interface.ts | 2 +-
8 files changed, 272 insertions(+), 53 deletions(-)
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') {
+