Browse Source

Feature/move total return from experimental to general availability (#7990)

* Move total return from experimental to general availability

* Update changelog
pull/7937/merge
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
07220c20be
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 2
      README.md
  3. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 140
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  5. 166
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy-with-dividend.spec.ts
  6. 134
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts
  7. 42
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  8. 42
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  9. 3
      apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts
  10. 1
      apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts
  11. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  12. 3
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  13. 6
      apps/api/src/app/portfolio/portfolio.service.ts
  14. 69
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  15. 30
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  16. 4
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  17. 35
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  18. 84
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Moved the performance calculation including dividends (total return) from experimental to general availability
### Fixed ### Fixed
- Fixed the visibility of the asset class, asset sub class, fee and quantity fields of a valuable in the create activity dialog - Fixed the visibility of the asset class, asset sub class, fee and quantity fields of a valuable in the create activity dialog

2
README.md

@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance: Return on Average Investment (ROAI) including dividends (total return) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions

2
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -79,7 +79,7 @@ export class GhostfolioMcpController {
}, },
description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join(
', ' ', '
)}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and excludes the dividends. The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, )}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`,
name: 'get-performance', name: 'get-performance',
parameters: GET_PERFORMANCE_PARAMETERS parameters: GET_PERFORMANCE_PARAMETERS
}) })

140
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -223,19 +223,15 @@ 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
@ -381,6 +377,7 @@ export abstract class PortfolioCalculator {
averageInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; averageInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
currentValues: { [date: string]: Big }; currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big };
dividendValuesWithCurrencyEffect: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big }; investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
investmentValuesWithCurrencyEffect: { [date: string]: Big }; investmentValuesWithCurrencyEffect: { [date: string]: Big };
@ -416,6 +413,7 @@ export abstract class PortfolioCalculator {
averageInvestmentWithCurrencyEffect, averageInvestmentWithCurrencyEffect,
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
dividendYieldPercent, dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect, dividendYieldPercentWithCurrencyEffect,
grossPerformance, grossPerformance,
@ -463,6 +461,7 @@ export abstract class PortfolioCalculator {
averageInvestmentValuesWithCurrencyEffect: {}, averageInvestmentValuesWithCurrencyEffect: {},
currentValues: {}, currentValues: {},
currentValuesWithCurrencyEffect: {}, currentValuesWithCurrencyEffect: {},
dividendValuesWithCurrencyEffect: {},
investmentValuesAccumulated: {}, investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {}, investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {}, investmentValuesWithCurrencyEffect: {},
@ -475,6 +474,7 @@ export abstract class PortfolioCalculator {
averageInvestmentValuesWithCurrencyEffect, averageInvestmentValuesWithCurrencyEffect,
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
investmentValuesAccumulated, investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect, investmentValuesWithCurrencyEffect,
@ -558,13 +558,6 @@ export abstract class PortfolioCalculator {
} }
} }
const totalDividendValueWithCurrencyEffectByDate =
this.getTotalDividendValueWithCurrencyEffectByDate({
chartDates,
exchangeRatesByCurrency,
holdings: positions
});
const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier);
for (const dateString of chartDates) { for (const dateString of chartDates) {
@ -579,6 +572,10 @@ export abstract class PortfolioCalculator {
assetProfileValues.currentValuesWithCurrencyEffect?.[dateString] ?? assetProfileValues.currentValuesWithCurrencyEffect?.[dateString] ??
new Big(0); new Big(0);
const dividendValueWithCurrencyEffect =
assetProfileValues.dividendValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const investmentValueAccumulated = const investmentValueAccumulated =
assetProfileValues.investmentValuesAccumulated?.[dateString] ?? assetProfileValues.investmentValuesAccumulated?.[dateString] ??
new Big(0); new Big(0);
@ -641,8 +638,10 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0) ?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect), ).add(currentValueWithCurrencyEffect),
totalDividendValueWithCurrencyEffect: totalDividendValueWithCurrencyEffect: (
totalDividendValueWithCurrencyEffectByDate[dateString], accumulatedValuesByDate[dateString]
?.totalDividendValueWithCurrencyEffect ?? new Big(0)
).add(dividendValueWithCurrencyEffect),
totalInvestmentValue: ( totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0) new Big(0)
@ -677,8 +676,7 @@ export abstract class PortfolioCalculator {
}); });
const performancePercentagesByDate = this.calculatePerformancePercentages({ const performancePercentagesByDate = this.calculatePerformancePercentages({
accumulatedValuesByDate, accumulatedValuesByDate
holdings: positionsIncludedInHoldings
}); });
const historicalData: HistoricalDataItem[] = Object.entries( const historicalData: HistoricalDataItem[] = Object.entries(
@ -880,12 +878,6 @@ export abstract class PortfolioCalculator {
return this.dataProviderInfos; return this.dataProviderInfos;
} }
public async getDividendInBaseCurrency() {
await this.snapshotPromise;
return this.getDividendInBaseCurrencyOfHoldings(this.snapshot.positions);
}
protected getDividendInBaseCurrencyOfHoldings( protected getDividendInBaseCurrencyOfHoldings(
holdings: PortfolioSnapshotHolding[] holdings: PortfolioSnapshotHolding[]
) { ) {
@ -904,6 +896,7 @@ export abstract class PortfolioCalculator {
averageInvestmentWithCurrencyEffect: new Big(0), averageInvestmentWithCurrencyEffect: new Big(0),
currentValues: {}, currentValues: {},
currentValuesWithCurrencyEffect: {}, currentValuesWithCurrencyEffect: {},
dividendValuesWithCurrencyEffect: {},
dividendYieldPercent: new Big(0), dividendYieldPercent: new Big(0),
dividendYieldPercentWithCurrencyEffect: new Big(0), dividendYieldPercentWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
@ -969,6 +962,10 @@ export abstract class PortfolioCalculator {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {}; const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
const dividendValuesWithCurrencyEffect: { [date: string]: Big } = {};
let dividends = new Big(0);
let dividendsAtStartDateWithCurrencyEffect = new Big(0);
let dividendsWithCurrencyEffect = new Big(0);
let fees = new Big(0); let fees = new Big(0);
let feesAtStartDate = new Big(0); let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0); let feesAtStartDateWithCurrencyEffect = new Big(0);
@ -1211,14 +1208,26 @@ export abstract class PortfolioCalculator {
); );
} }
if (activity.type === 'DIVIDEND') {
const dividend = activity.quantity.mul(activity.unitPrice);
dividends = dividends.plus(dividend.mul(currentExchangeRate ?? 1));
dividendsWithCurrencyEffect = dividendsWithCurrencyEffect.plus(
dividend.mul(exchangeRateAtActivityDate ?? 1)
);
}
const newGrossPerformance = valueOfInvestment const newGrossPerformance = valueOfInvestment
.minus(totalInvestment) .minus(totalInvestment)
.plus(grossPerformanceFromSells); .plus(grossPerformanceFromSells)
.plus(dividends);
const newGrossPerformanceWithCurrencyEffect = const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect) .minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect); .plus(grossPerformanceFromSellsWithCurrencyEffect)
.plus(dividendsWithCurrencyEffect);
grossPerformance = newGrossPerformance; grossPerformance = newGrossPerformance;
@ -1226,6 +1235,7 @@ export abstract class PortfolioCalculator {
newGrossPerformanceWithCurrencyEffect; newGrossPerformanceWithCurrencyEffect;
if (activity.itemType === 'start') { if (activity.itemType === 'start') {
dividendsAtStartDateWithCurrencyEffect = dividendsWithCurrencyEffect;
feesAtStartDate = fees; feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDate = grossPerformance;
@ -1240,6 +1250,11 @@ export abstract class PortfolioCalculator {
currentValuesWithCurrencyEffect[activity.date] = currentValuesWithCurrencyEffect[activity.date] =
valueOfInvestmentWithCurrencyEffect; valueOfInvestmentWithCurrencyEffect;
dividendValuesWithCurrencyEffect[activity.date] =
dividendsWithCurrencyEffect.minus(
dividendsAtStartDateWithCurrencyEffect
);
netPerformanceValues[activity.date] = grossPerformance netPerformanceValues[activity.date] = grossPerformance
.minus(grossPerformanceAtStartDate) .minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
@ -1313,6 +1328,7 @@ export abstract class PortfolioCalculator {
return { return {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue, initialValue,
investmentValuesAccumulated, investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect,
@ -1422,8 +1438,7 @@ 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) => {
@ -1785,77 +1800,6 @@ export abstract class PortfolioCalculator {
return chartDateMap; 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 @LogPerformance
private async initialize(attempt = 1) { private async initialize(attempt = 1) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();

166
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy-with-dividend.spec.ts

@ -0,0 +1,166 @@
import {
activityDummyData,
assetProfileDummyData,
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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
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 buy and dividend', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 5
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
// The asset performance takes the dividend at the current exchange rate
// (0.8854), the net performance at the exchange rate of the payment date
// (0.9238)
expect(portfolioSnapshot.positions).toMatchObject([
{
dividendInBaseCurrency: new Big('5').mul(0.9238),
grossPerformance: new Big('32.33').mul(0.8854),
grossPerformanceWithCurrencyEffect: new Big('25.394774'),
netPerformance: new Big('31.33').mul(0.8854),
netPerformanceWithCurrencyEffectMap: {
max: new Big('24.470974')
}
}
]);
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({
dividendInBaseCurrency: 4.619,
netPerformance: new Big('31.33').mul(0.8854).toNumber(),
netPerformanceWithCurrencyEffect: 24.470974
});
});
});
});

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

@ -122,8 +122,9 @@ describe('PortfolioCalculator', () => {
{ {
// The first activity of this asset profile is a fee, which is not an // The first activity of this asset profile is a fee, which is not an
// investment activity. Thus the holding is not included in the // investment activity. Thus the holding is not included in the
// holdings of the portfolio summary, and its dividend must not be // holdings of the portfolio summary. But its fee and its dividend are
// included in the chart // part of the net performance, and thus its dividend is part of the
// dividend of the chart
...activityDummyData, ...activityDummyData,
assetProfile: { assetProfile: {
...assetProfileDummyData, ...assetProfileDummyData,
@ -167,8 +168,10 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const dividendInBaseCurrency = const performanceByDateRange = await getPerformanceByDateRange({
await portfolioCalculator.getDividendInBaseCurrency(); portfolioCalculator,
dateRanges: ['max']
});
expect( expect(
portfolioSnapshot.positions.map(({ symbol }) => { portfolioSnapshot.positions.map(({ symbol }) => {
@ -176,12 +179,22 @@ describe('PortfolioCalculator', () => {
}) })
).toEqual(['MSFT']); ).toEqual(['MSFT']);
// The chart and the portfolio summary must show the same dividend expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({
expect(dividendInBaseCurrency).toEqual(new Big('0.62')); dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0.01882242615044544,
netPerformance: 18.87,
netPerformanceInPercentage: 0.06319914260834618
});
expect( // The portfolio summary takes the dividend of the date range max
portfolioSnapshot.historicalData.at(-1).dividendInBaseCurrency expect(performanceByDateRange).toMatchObject({
).toEqual(0.62); max: {
dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0.01882242615044535,
netPerformance: 18.87,
netPerformanceInPercentage: 0.06319914260834586
}
});
}); });
it('with GOOGL dividend without investment', async () => { it('with GOOGL dividend without investment', async () => {
@ -221,8 +234,8 @@ describe('PortfolioCalculator', () => {
unitPriceInAssetProfileCurrency: 0.62 unitPriceInAssetProfileCurrency: 0.62
}, },
{ {
// The holding has a dividend, but no average investment. Thus the // The holding has a dividend, but no average investment. Its dividend
// dividend percentage stays 0, like the dividend yield // is part of the net performance, and thus of the dividend percentage
...activityDummyData, ...activityDummyData,
assetProfile: { assetProfile: {
...assetProfileDummyData, ...assetProfileDummyData,
@ -261,13 +274,108 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({
dividendInBaseCurrency: 5.62, dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0 dividendInPercentageWithCurrencyEffect: 0.01882242615044544,
netPerformance: 19.87,
netPerformanceInPercentage: 0.06654832875611226
}); });
expect(performanceByDateRange).toMatchObject({ expect(performanceByDateRange).toMatchObject({
max: { max: {
dividendInBaseCurrency: 5.62, dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0 dividendInPercentageWithCurrencyEffect: 0.01882242615044535,
netPerformance: 19.87,
netPerformanceInPercentage: 0.06654832875611194
}
});
});
it('with dividend of a holding without market price', 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 no market price, thus it has no value. Its dividend
// is not part of the net performance (unlike the dividend in the
// scenario above), and thus not part of the dividend of the chart
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Private Equity Fund',
symbol: '3b5ba4a5-4c8e-4bd5-9b4c-5e0d5e7cfc3f'
},
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.hasErrors).toBe(false);
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({
dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.0020764954116149776,
netPerformance: 14.87,
netPerformanceInPercentage: 0.0498023980172818
});
expect(performanceByDateRange).toMatchObject({
max: {
dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.002076495411614967,
netPerformance: 14.87,
netPerformanceInPercentage: 0.049802398017281556
} }
}); });
}); });

42
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -122,25 +122,25 @@ describe('PortfolioCalculator', () => {
'0.001144362748184' '0.001144362748184'
), ),
fee: new Big('19'), fee: new Big('19'),
grossPerformance: new Big('33.25'), grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11136043941322258691'), grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11136043941322258691' '0.11343693482483756447'
), ),
grossPerformanceWithCurrencyEffect: new Big('33.25'), grossPerformanceWithCurrencyEffect: new Big('33.87'),
investment: new Big('298.58'), investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'), investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83, marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83, marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.25'), netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04772590260566682296'), netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04772590260566682296') max: new Big('0.04980239801728180052')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'), '1d': new Big('-5.39'),
'5y': new Big('14.25'), '5y': new Big('14.87'),
max: new Big('14.25'), max: new Big('14.87'),
wtd: new Big('-5.39') wtd: new Big('-5.39')
}, },
quantity: new Big('1'), quantity: new Big('1'),
@ -167,10 +167,10 @@ describe('PortfolioCalculator', () => {
date: '2023-07-10', date: '2023-07-10',
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0, dividendInPercentageWithCurrencyEffect: 0,
netPerformance: -5.390000000000001, netPerformance: -5.390000000000002,
netPerformanceInPercentage: -0.015983630864124312, netPerformanceInPercentage: -0.015983630864124316,
netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124312, netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124316,
netPerformanceWithCurrencyEffect: -5.390000000000001, netPerformanceWithCurrencyEffect: -5.390000000000002,
totalInvestmentValueWithCurrencyEffect: 298.58, totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83 valueWithCurrencyEffect: 331.83
}, },
@ -178,10 +178,10 @@ describe('PortfolioCalculator', () => {
date: '2023-07-10', date: '2023-07-10',
dividendInBaseCurrency: 0.62, dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.002076495411614967, dividendInPercentageWithCurrencyEffect: 0.002076495411614967,
netPerformance: 14.25, netPerformance: 14.87,
netPerformanceInPercentage: 0.04772590260566659, netPerformanceInPercentage: 0.049802398017281556,
netPerformanceInPercentageWithCurrencyEffect: 0.04772590260566659, netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281556,
netPerformanceWithCurrencyEffect: 14.25, netPerformanceWithCurrencyEffect: 14.87,
totalInvestmentValueWithCurrencyEffect: 298.58, totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83 valueWithCurrencyEffect: 331.83
}, },
@ -189,10 +189,10 @@ describe('PortfolioCalculator', () => {
date: '2023-07-10', date: '2023-07-10',
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0, dividendInPercentageWithCurrencyEffect: 0,
netPerformance: -7.68, netPerformance: -7.6800000000000015,
netPerformanceInPercentage: -0.022620835910577012, netPerformanceInPercentage: -0.022620835910577015,
netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577012, netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577015,
netPerformanceWithCurrencyEffect: -7.68, netPerformanceWithCurrencyEffect: -7.6800000000000015,
totalInvestmentValueWithCurrencyEffect: 298.58, totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83 valueWithCurrencyEffect: 331.83
} }

42
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -17,10 +17,7 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
HistoricalDataItem HistoricalDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { PortfolioSnapshot } from '@ghostfolio/common/models';
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';
@ -108,8 +105,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
? differenceInDays(new Date(), dateOfFirstActivity) ? differenceInDays(new Date(), dateOfFirstActivity)
: 0; : 0;
// Take the dividend from the same source as the portfolio summary, so
// that the response shows one dividend only
const totalDividendInBaseCurrency = const totalDividendInBaseCurrency =
this.getDividendInBaseCurrencyOfHoldings(positions); this.getDividendInBaseCurrencyOfHoldings(positions);
@ -168,15 +163,10 @@ 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;
} = {}; } = {};
@ -193,7 +183,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
] of Object.entries(accumulatedValuesByDate)) { ] of Object.entries(accumulatedValuesByDate)) {
performancePercentagesByDate[date] = { performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect: dividendInPercentageWithCurrencyEffect:
hasDividendWithoutAverageInvestment ||
totalAverageInvestmentValueWithCurrencyEffect.eq(0) totalAverageInvestmentValueWithCurrencyEffect.eq(0)
? 0 ? 0
: totalDividendValueWithCurrencyEffect : totalDividendValueWithCurrencyEffect
@ -217,15 +206,10 @@ 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;
@ -276,7 +260,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
performancePercentagesByDate[historicalDataItem.date] = { performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect: dividendInPercentageWithCurrencyEffect:
!hasDividendWithoutAverageInvestment &&
averageInvestmentValueWithCurrencyEffect > 0 averageInvestmentValueWithCurrencyEffect > 0
? historicalDataItem.dividendInBaseCurrency / ? historicalDataItem.dividendInBaseCurrency /
averageInvestmentValueWithCurrencyEffect averageInvestmentValueWithCurrencyEffect
@ -403,6 +386,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const { const {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue, initialValue,
investmentValuesAccumulated, investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect,
@ -736,6 +720,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
averageInvestmentValuesWithCurrencyEffect, averageInvestmentValuesWithCurrencyEffect,
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
dividendYieldPercent, dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect, dividendYieldPercentWithCurrencyEffect,
grossPerformancePercentage, grossPerformancePercentage,
@ -768,21 +753,4 @@ 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)
);
}
);
}
} }

3
apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts

@ -17,6 +17,9 @@ export interface HoldingPerformance {
currentValuesWithCurrencyEffect: { currentValuesWithCurrencyEffect: {
[date: string]: Big; [date: string]: Big;
}; };
dividendValuesWithCurrencyEffect: {
[date: string]: Big;
};
dividendYieldPercent: Big; dividendYieldPercent: Big;
dividendYieldPercentWithCurrencyEffect: Big; dividendYieldPercentWithCurrencyEffect: Big;
grossPerformance: Big; grossPerformance: Big;

1
apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts

@ -5,6 +5,7 @@ import { HoldingValuationItem } from './holding-valuation-item.interface';
export interface HoldingValuation { export interface HoldingValuation {
currentValues: { [date: string]: Big }; currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big };
dividendValuesWithCurrencyEffect: { [date: string]: Big };
initialValue?: Big; initialValue?: Big;
investmentValuesAccumulated: { [date: string]: Big }; investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };

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

@ -524,6 +524,7 @@ export class PortfolioController {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
({ ({
date, date,
dividendInPercentageWithCurrencyEffect,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect,
netWorth, netWorth,
@ -532,6 +533,7 @@ export class PortfolioController {
}) => { }) => {
return { return {
date, date,
dividendInPercentageWithCurrencyEffect,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage: netWorthInPercentage:

3
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -865,7 +865,6 @@ describe('PortfolioService', () => {
function createPortfolioCalculator() { function createPortfolioCalculator() {
return { return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
@ -891,6 +890,7 @@ describe('PortfolioService', () => {
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: { performance: {
currentValueInBaseCurrency: 3000, currentValueInBaseCurrency: 3000,
dividendInBaseCurrency: 50,
netPerformance: 500, netPerformance: 500,
netPerformancePercentage: 0.2, netPerformancePercentage: 0.2,
netPerformancePercentageWithCurrencyEffect: 0.2, netPerformancePercentageWithCurrencyEffect: 0.2,
@ -926,6 +926,7 @@ describe('PortfolioService', () => {
}); });
expect(summary.cash).toBe(1000); expect(summary.cash).toBe(1000);
expect(summary.dividendInBaseCurrency).toBe(50);
expect(summary.emergencyFund.total).toBe(0); expect(summary.emergencyFund.total).toBe(0);
expect(summary.excludedAccountsAndActivities).toBe(0); expect(summary.excludedAccountsAndActivities).toBe(0);
expect(summary.totalAssetsInBaseCurrency).toBe(3000); expect(summary.totalAssetsInBaseCurrency).toBe(3000);

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

@ -2140,6 +2140,7 @@ export class PortfolioService {
const { const {
currentValueInBaseCurrency, currentValueInBaseCurrency,
dividendInBaseCurrency,
dividendPercentageWithCurrencyEffect, dividendPercentageWithCurrencyEffect,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
@ -2154,9 +2155,6 @@ export class PortfolioService {
const dateOfFirstActivity = portfolioCalculator.getStartDate(); const dateOfFirstActivity = portfolioCalculator.getStartDate();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const fees = await portfolioCalculator.getFeesInBaseCurrency(); const fees = await portfolioCalculator.getFeesInBaseCurrency();
const interest = await portfolioCalculator.getInterestInBaseCurrency(); const interest = await portfolioCalculator.getInterestInBaseCurrency();
@ -2237,6 +2235,7 @@ export class PortfolioService {
cash, cash,
currentValueInBaseCurrency, currentValueInBaseCurrency,
dateOfFirstActivity, dateOfFirstActivity,
dividendInBaseCurrency,
dividendPercentageWithCurrencyEffect, dividendPercentageWithCurrencyEffect,
excludedAccountsAndActivities, excludedAccountsAndActivities,
netPerformance, netPerformance,
@ -2248,7 +2247,6 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => { activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
// TODO: Remove the fallback to 0 with the next release, when each // TODO: Remove the fallback to 0 with the next release, when each
// cached portfolio snapshot contains the dividend yield // cached portfolio snapshot contains the dividend yield
dividendYieldPercent: dividendYieldPercent?.toNumber() ?? 0, dividendYieldPercent: dividendYieldPercent?.toNumber() ?? 0,

69
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -98,25 +98,20 @@
/> />
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) { <div class="flex-nowrap px-3 py-1 row text-muted">
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-grow-1 indent-1 text-truncate" i18n>thereof Dividend</div>
<div class="flex-grow-1 text-truncate" i18n>Dividend</div> <div class="justify-content-end">
<div class="d-flex justify-content-end"> <gf-value
@if (isNumber(summary?.dividendInBaseCurrency)) { class="justify-content-end"
<span class="mr-1">+</span> [isCurrency]="true"
} [isLoading]="isLoading"
<gf-value [locale]="locale"
class="justify-content-end" [precision]="precision"
[isCurrency]="true" [unit]="displayedCurrency"
[isLoading]="isLoading" [value]="summary?.dividendInBaseCurrency"
[locale]="locale" />
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
</div>
</div> </div>
} </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Fees</div> <div class="flex-grow-1 text-truncate" i18n>Fees</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -147,7 +142,7 @@
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="displayedCurrency" [unit]="displayedCurrency"
[value]="netPerformanceWithCurrencyEffect" [value]="summary?.netPerformanceWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -160,7 +155,8 @@
) { ) {
<abbr <abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted" class="d-none d-sm-inline-block initialism ml-2 text-muted"
title="Return on Average Investment" i18n-title
title="Return on Average Investment (total return with dividends)"
>(ROAI)</abbr >(ROAI)</abbr
> >
} }
@ -173,7 +169,22 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="netPerformancePercentageWithCurrencyEffect" [value]="summary?.netPerformancePercentageWithCurrencyEffect"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row text-muted">
<div class="flex-grow-1 indent-2 text-truncate">
<ng-container i18n>thereof Dividend Performance</ng-container>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isLoading]="isLoading"
[isPercent]="true"
[locale]="locale"
[value]="summary?.dividendPercentageWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -506,20 +517,4 @@
/> />
</div> </div>
</div> </div>
@if (!user?.settings?.isExperimentalFeatures) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
</div>
</div>
}
</div> </div>

30
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -137,36 +137,6 @@ export class GfPortfolioSummaryComponent implements OnChanges {
); );
} }
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformancePercentageWithCurrencyEffect)
) {
return this.summary?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformancePercentageWithCurrencyEffect +
(this.summary.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformanceWithCurrencyEffect)
) {
return this.summary?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformanceWithCurrencyEffect +
this.summary.dividendInBaseCurrency
);
}
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if ( if (

4
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -52,8 +52,8 @@
calculation method based on the average amount of capital invested calculation method based on the average amount of capital invested
over time. ROAI aims to provide a more insightful view of investment over time. ROAI aims to provide a more insightful view of investment
performance than simpler approaches, especially when contributions are performance than simpler approaches, especially when contributions are
made over time. Dividends are not part of the made over time. Dividends are included in the calculation (total
calculation.</mat-card-content return).</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">

35
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -133,36 +133,6 @@ export class GfAnalysisPageComponent implements OnInit {
addIcons({ copyOutline, ellipsisVertical }); addIcons({ copyOutline, ellipsisVertical });
} }
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformancePercentageWithCurrencyEffect)
) {
return this.performance?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformancePercentageWithCurrencyEffect +
(this.performance.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformanceWithCurrencyEffect)
) {
return this.performance?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformanceWithCurrencyEffect +
this.performance.dividendInBaseCurrency
);
}
get savingsRate() { get savingsRate() {
if (!this.savingsRatePerMonth) { if (!this.savingsRatePerMonth) {
return undefined; return undefined;
@ -363,6 +333,7 @@ export class GfAnalysisPageComponent implements OnInit {
index, index,
{ {
date, date,
dividendInPercentageWithCurrencyEffect,
netPerformanceInPercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
valueInPercentage, valueInPercentage,
@ -393,7 +364,9 @@ export class GfAnalysisPageComponent implements OnInit {
if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) { if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) {
this.performanceDataItemsInPercentage.push({ this.performanceDataItemsInPercentage.push({
date, date,
value: netPerformanceInPercentageWithCurrencyEffect value:
netPerformanceInPercentageWithCurrencyEffect -
(dividendInPercentageWithCurrencyEffect ?? 0)
}); });
} }
} }

84
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -187,45 +187,6 @@
/> />
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>Absolute Dividend Performance</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex mb-3 ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/>
</div>
</div>
}
<div class="d-flex py-1"> <div class="d-flex py-1">
<div <div
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"
@ -286,7 +247,7 @@
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
[value]="netPerformanceWithCurrencyEffect" [value]="performance?.netPerformanceWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -299,7 +260,8 @@
) { ) {
<abbr <abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted" class="d-none d-sm-inline-block initialism ml-2 text-muted"
title="Return on Average Investment" i18n-title
title="Return on Average Investment (total return with dividends)"
>(ROAI)</abbr >(ROAI)</abbr
> >
} }
@ -312,7 +274,45 @@
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="netPerformancePercentageWithCurrencyEffect" [value]="
performance?.netPerformancePercentageWithCurrencyEffect
"
/>
</div>
</div>
<div class="d-flex ml-3 py-1 text-muted">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>thereof Dividend</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex ml-3 py-1 text-muted">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>thereof Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/> />
</div> </div>
</div> </div>

Loading…
Cancel
Save