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; ): PortfolioSnapshot;
protected abstract calculatePerformancePercentages({ protected abstract calculatePerformancePercentages({
accumulatedValuesByDate accumulatedValuesByDate,
holdings
}: { }: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues }; accumulatedValuesByDate: { [date: string]: AccumulatedValues };
holdings: PortfolioSnapshotHolding[];
}): { [date: string]: PerformancePercentages }; }): { [date: string]: PerformancePercentages };
protected abstract calculatePerformancePercentagesForDateRange({ protected abstract calculatePerformancePercentagesForDateRange({
historicalDataItems historicalDataItems,
holdings
}: { }: {
historicalDataItems: HistoricalDataItem[]; historicalDataItems: HistoricalDataItem[];
holdings: PortfolioSnapshotHolding[];
}): { [date: string]: PerformancePercentages }; }): { [date: string]: PerformancePercentages };
@LogPerformance @LogPerformance
@ -544,11 +548,12 @@ export abstract class PortfolioCalculator {
} }
} }
const totalDividendValueByDate = this.getTotalDividendValueByDate({ const totalDividendValueWithCurrencyEffectByDate =
chartDates, this.getTotalDividendValueWithCurrencyEffectByDate({
exchangeRatesByCurrency, chartDates,
holdings: positions exchangeRatesByCurrency,
}); holdings: positions
});
const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier);
@ -626,7 +631,8 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0) ?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect), ).add(currentValueWithCurrencyEffect),
totalDividendValue: totalDividendValueByDate[dateString], totalDividendValueWithCurrencyEffect:
totalDividendValueWithCurrencyEffectByDate[dateString],
totalInvestmentValue: ( totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0) 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({ const performancePercentagesByDate = this.calculatePerformancePercentages({
accumulatedValuesByDate accumulatedValuesByDate,
holdings: positionsIncludedInHoldings
}); });
const historicalData: HistoricalDataItem[] = Object.entries( const historicalData: HistoricalDataItem[] = Object.entries(
@ -663,7 +679,7 @@ export abstract class PortfolioCalculator {
totalCashValueWithCurrencyEffect, totalCashValueWithCurrencyEffect,
totalCurrentValue, totalCurrentValue,
totalCurrentValueWithCurrencyEffect, totalCurrentValueWithCurrencyEffect,
totalDividendValue, totalDividendValueWithCurrencyEffect,
totalInvestmentValue, totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue, totalNetPerformanceValue,
@ -674,7 +690,7 @@ export abstract class PortfolioCalculator {
return { return {
...performancePercentagesByDate[date], ...performancePercentagesByDate[date],
date, date,
dividendInBaseCurrency: totalDividendValue.toNumber(), dividendInBaseCurrency: totalDividendValueWithCurrencyEffect.toNumber(),
investmentValueWithCurrencyEffect: investmentValueWithCurrencyEffect:
investmentValueWithCurrencyEffect.toNumber(), investmentValueWithCurrencyEffect.toNumber(),
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
@ -692,15 +708,6 @@ export abstract class PortfolioCalculator {
const overall = this.calculateOverallPerformance(positions); 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 { return {
...overall, ...overall,
errors, errors,
@ -1363,6 +1370,8 @@ export abstract class PortfolioCalculator {
// Take the values at the start date from the first day of the date // Take the values at the start date from the first day of the date
// range // range
if (historicalDataItemsOfDateRange.length === 0) { if (historicalDataItemsOfDateRange.length === 0) {
// TODO: Remove the fallback to 0 with the next release, when each
// cached portfolio snapshot contains the dividend
dividendInBaseCurrencyAtStartDate = dividendInBaseCurrencyAtStartDate =
historicalDataItem.dividendInBaseCurrency ?? 0; historicalDataItem.dividendInBaseCurrency ?? 0;
@ -1390,7 +1399,8 @@ export abstract class PortfolioCalculator {
const performancePercentagesByDate = const performancePercentagesByDate =
this.calculatePerformancePercentagesForDateRange({ this.calculatePerformancePercentagesForDateRange({
historicalDataItems: historicalDataItemsOfDateRange historicalDataItems: historicalDataItemsOfDateRange,
holdings: this.snapshot.positions
}); });
const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => { const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => {
@ -1752,7 +1762,7 @@ export abstract class PortfolioCalculator {
return chartDateMap; return chartDateMap;
} }
private getTotalDividendValueByDate({ private getTotalDividendValueWithCurrencyEffectByDate({
chartDates, chartDates,
exchangeRatesByCurrency, exchangeRatesByCurrency,
holdings holdings
@ -1786,10 +1796,11 @@ export abstract class PortfolioCalculator {
} }
); );
const totalDividendValueByDate: { [date: string]: Big } = {}; const totalDividendValueWithCurrencyEffectByDate: { [date: string]: Big } =
{};
let index = 0; 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 // The activities and the chart dates are sorted by date, so one pass over
// both gives the dividends received up to each chart date // both gives the dividends received up to each chart date
@ -1801,23 +1812,25 @@ export abstract class PortfolioCalculator {
const { assetProfile, date, quantity, unitPrice } = const { assetProfile, date, quantity, unitPrice } =
dividendActivities[index]; dividendActivities[index];
totalDividendValue = totalDividendValue.plus( totalDividendValueWithCurrencyEffect =
quantity totalDividendValueWithCurrencyEffect.plus(
.mul(unitPrice) quantity
.mul( .mul(unitPrice)
exchangeRatesByCurrency[ .mul(
`${assetProfile.currency}${this.currency}` exchangeRatesByCurrency[
]?.[date] ?? 1 `${assetProfile.currency}${this.currency}`
) ]?.[date] ?? 1
); )
);
index++; index++;
} }
totalDividendValueByDate[chartDate] = totalDividendValue; totalDividendValueWithCurrencyEffectByDate[chartDate] =
totalDividendValueWithCurrencyEffect;
} }
return totalDividendValueByDate; return totalDividendValueWithCurrencyEffectByDate;
} }
@LogPerformance @LogPerformance

176
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts

@ -1,6 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData, assetProfileDummyData,
getPerformanceByDateRange,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -182,5 +183,180 @@ describe('PortfolioCalculator', () => {
portfolioSnapshot.historicalData.at(-1).dividendInBaseCurrency portfolioSnapshot.historicalData.at(-1).dividendInBaseCurrency
).toEqual(0.62); ).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, AssetProfileIdentifier,
HistoricalDataItem HistoricalDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import {
PortfolioSnapshot,
PortfolioSnapshotHolding
} from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -165,10 +168,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
protected calculatePerformancePercentages({ protected calculatePerformancePercentages({
accumulatedValuesByDate accumulatedValuesByDate,
holdings
}: { }: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues }; accumulatedValuesByDate: { [date: string]: AccumulatedValues };
holdings: PortfolioSnapshotHolding[];
}): { [date: string]: PerformancePercentages } { }): { [date: string]: PerformancePercentages } {
const hasDividendWithoutAverageInvestment =
this.hasDividendWithoutAverageInvestment(holdings);
const performancePercentagesByDate: { const performancePercentagesByDate: {
[date: string]: PerformancePercentages; [date: string]: PerformancePercentages;
} = {}; } = {};
@ -178,16 +186,17 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
{ {
totalAverageInvestmentValue, totalAverageInvestmentValue,
totalAverageInvestmentValueWithCurrencyEffect, totalAverageInvestmentValueWithCurrencyEffect,
totalDividendValue, totalDividendValueWithCurrencyEffect,
totalNetPerformanceValue, totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect totalNetPerformanceValueWithCurrencyEffect
} }
] of Object.entries(accumulatedValuesByDate)) { ] of Object.entries(accumulatedValuesByDate)) {
performancePercentagesByDate[date] = { performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect: dividendInPercentageWithCurrencyEffect:
hasDividendWithoutAverageInvestment ||
totalAverageInvestmentValueWithCurrencyEffect.eq(0) totalAverageInvestmentValueWithCurrencyEffect.eq(0)
? 0 ? 0
: totalDividendValue : totalDividendValueWithCurrencyEffect
.div(totalAverageInvestmentValueWithCurrencyEffect) .div(totalAverageInvestmentValueWithCurrencyEffect)
.toNumber(), .toNumber(),
netPerformanceInPercentage: totalAverageInvestmentValue.eq(0) netPerformanceInPercentage: totalAverageInvestmentValue.eq(0)
@ -208,10 +217,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
protected calculatePerformancePercentagesForDateRange({ protected calculatePerformancePercentagesForDateRange({
historicalDataItems historicalDataItems,
holdings
}: { }: {
historicalDataItems: HistoricalDataItem[]; historicalDataItems: HistoricalDataItem[];
holdings: PortfolioSnapshotHolding[];
}): { [date: string]: PerformancePercentages } { }): { [date: string]: PerformancePercentages } {
const hasDividendWithoutAverageInvestment =
this.hasDividendWithoutAverageInvestment(holdings);
const averageInvestmentValues: number[] = []; const averageInvestmentValues: number[] = [];
const averageInvestmentValuesWithCurrencyEffect: number[] = []; const averageInvestmentValuesWithCurrencyEffect: number[] = [];
let grossPerformanceAtStartDate: number; let grossPerformanceAtStartDate: number;
@ -262,6 +276,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
performancePercentagesByDate[historicalDataItem.date] = { performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect: dividendInPercentageWithCurrencyEffect:
!hasDividendWithoutAverageInvestment &&
averageInvestmentValueWithCurrencyEffect > 0 averageInvestmentValueWithCurrencyEffect > 0
? historicalDataItem.dividendInBaseCurrency / ? historicalDataItem.dividendInBaseCurrency /
averageInvestmentValueWithCurrencyEffect averageInvestmentValueWithCurrencyEffect
@ -753,4 +768,21 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected getPerformanceCalculationType() { protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROAI; 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; totalCashValueWithCurrencyEffect: Big;
totalCurrentValue: Big; totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big; totalCurrentValueWithCurrencyEffect: Big;
totalDividendValue: Big; totalDividendValueWithCurrencyEffect: Big;
totalInvestmentValue: Big; totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big; totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big; totalNetPerformanceValue: Big;

8
apps/api/src/app/portfolio/portfolio.controller.ts

@ -581,12 +581,16 @@ export class PortfolioController {
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
(item) => { (item) => {
return nullifyValuesInObject(item, ['totalInvestment', 'value']); return nullifyValuesInObject(item, [
'dividendInBaseCurrency',
'totalInvestment',
'value'
]);
} }
); );
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
['netPerformance'] ['dividendInBaseCurrency', 'netPerformance']
); );
} }

2
apps/api/src/app/portfolio/portfolio.service.ts

@ -2136,7 +2136,6 @@ export class PortfolioService {
const { const {
currentValueInBaseCurrency, currentValueInBaseCurrency,
dividendPercentageWithCurrencyEffect,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
@ -2233,7 +2232,6 @@ export class PortfolioService {
cash, cash,
currentValueInBaseCurrency, currentValueInBaseCurrency,
dateOfFirstActivity, dateOfFirstActivity,
dividendPercentageWithCurrencyEffect,
excludedAccountsAndActivities, excludedAccountsAndActivities,
netPerformance, netPerformance,
netPerformancePercentage, 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" class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
> >
<span i18n>Absolute Dividend Performance</span> <span i18n>Absolute Dividend Performance</span>
<small @if (user?.subscription?.type === 'Basic') {
class="d-none d-sm-inline-block ml-2 text-muted" <gf-premium-indicator class="ml-1" />
i18n }
i18n-title
title="The dividends are not part of the net performance"
>(not included)</small
>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value

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

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

Loading…
Cancel
Save