Browse Source

Add dividend performance

pull/7924/head
Thomas Kaul 24 hours ago
parent
commit
fbadb1eb1f
  1. 83
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 176
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts
  3. 42
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  4. 2
      apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts
  5. 8
      apps/api/src/app/portfolio/portfolio.controller.ts
  6. 2
      apps/api/src/app/portfolio/portfolio.service.ts
  7. 10
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  8. 2
      libs/common/src/lib/interfaces/portfolio-performance.interface.ts

83
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

176
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
}
});
});
});
});

42
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)
);
}
);
}
}

2
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;

8
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']
);
}

2
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,

10
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"
>
<span i18n>Absolute Dividend Performance</span>
<small
class="d-none d-sm-inline-block ml-2 text-muted"
i18n
i18n-title
title="The dividends are not part of the net performance"
>(not included)</small
>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value

2
libs/common/src/lib/interfaces/portfolio-performance.interface.ts

@ -3,7 +3,7 @@ export interface PortfolioPerformance {
currentNetWorth?: number;
currentValueInBaseCurrency: number;
dividendInBaseCurrency: number;
dividendPercentageWithCurrencyEffect: number;
dividendPercentageWithCurrencyEffect?: number;
netPerformance: number;
netPerformancePercentage: number;
netPerformancePercentageWithCurrencyEffect: number;

Loading…
Cancel
Save