diff --git a/CHANGELOG.md b/CHANGELOG.md index 117e8e5125..f9ef57d192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Moved the performance calculation including dividends (total return) from experimental to general availability + ### Fixed - Fixed the visibility of the asset class, asset sub class, fee and quantity fields of a valuable in the create activity dialog diff --git a/README.md b/README.md index 7c88d99919..95bf3a5ea3 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Ghostfolio is for you if you are... - ✅ Create, update and delete transactions - ✅ 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 - ✅ Static analysis to identify potential risks in your portfolio - ✅ Import and export transactions diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 742080498b..71aaeecccd 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/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( ', ' - )}. 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', parameters: GET_PERFORMANCE_PARAMETERS }) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index b9cb622713..9b94545fd8 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -223,19 +223,15 @@ export abstract class PortfolioCalculator { ): PortfolioSnapshot; protected abstract calculatePerformancePercentages({ - accumulatedValuesByDate, - holdings + accumulatedValuesByDate }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages }; protected abstract calculatePerformancePercentagesForDateRange({ - historicalDataItems, - holdings + historicalDataItems }: { historicalDataItems: HistoricalDataItem[]; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages }; @LogPerformance @@ -381,6 +377,7 @@ export abstract class PortfolioCalculator { averageInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; currentValues: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big }; + dividendValuesWithCurrencyEffect: { [date: string]: Big }; investmentValuesAccumulated: { [date: string]: Big }; investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; investmentValuesWithCurrencyEffect: { [date: string]: Big }; @@ -416,6 +413,7 @@ export abstract class PortfolioCalculator { averageInvestmentWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, dividendYieldPercent, dividendYieldPercentWithCurrencyEffect, grossPerformance, @@ -463,6 +461,7 @@ export abstract class PortfolioCalculator { averageInvestmentValuesWithCurrencyEffect: {}, currentValues: {}, currentValuesWithCurrencyEffect: {}, + dividendValuesWithCurrencyEffect: {}, investmentValuesAccumulated: {}, investmentValuesAccumulatedWithCurrencyEffect: {}, investmentValuesWithCurrencyEffect: {}, @@ -475,6 +474,7 @@ export abstract class PortfolioCalculator { averageInvestmentValuesWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, investmentValuesAccumulated, investmentValuesAccumulatedWithCurrencyEffect, investmentValuesWithCurrencyEffect, @@ -558,13 +558,6 @@ export abstract class PortfolioCalculator { } } - const totalDividendValueWithCurrencyEffectByDate = - this.getTotalDividendValueWithCurrencyEffectByDate({ - chartDates, - exchangeRatesByCurrency, - holdings: positions - }); - const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); for (const dateString of chartDates) { @@ -579,6 +572,10 @@ export abstract class PortfolioCalculator { assetProfileValues.currentValuesWithCurrencyEffect?.[dateString] ?? new Big(0); + const dividendValueWithCurrencyEffect = + assetProfileValues.dividendValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const investmentValueAccumulated = assetProfileValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); @@ -641,8 +638,10 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) ).add(currentValueWithCurrencyEffect), - totalDividendValueWithCurrencyEffect: - totalDividendValueWithCurrencyEffectByDate[dateString], + totalDividendValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalDividendValueWithCurrencyEffect ?? new Big(0) + ).add(dividendValueWithCurrencyEffect), totalInvestmentValue: ( accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) @@ -677,8 +676,7 @@ export abstract class PortfolioCalculator { }); const performancePercentagesByDate = this.calculatePerformancePercentages({ - accumulatedValuesByDate, - holdings: positionsIncludedInHoldings + accumulatedValuesByDate }); const historicalData: HistoricalDataItem[] = Object.entries( @@ -880,12 +878,6 @@ export abstract class PortfolioCalculator { return this.dataProviderInfos; } - public async getDividendInBaseCurrency() { - await this.snapshotPromise; - - return this.getDividendInBaseCurrencyOfHoldings(this.snapshot.positions); - } - protected getDividendInBaseCurrencyOfHoldings( holdings: PortfolioSnapshotHolding[] ) { @@ -904,6 +896,7 @@ export abstract class PortfolioCalculator { averageInvestmentWithCurrencyEffect: new Big(0), currentValues: {}, currentValuesWithCurrencyEffect: {}, + dividendValuesWithCurrencyEffect: {}, dividendYieldPercent: new Big(0), dividendYieldPercentWithCurrencyEffect: new Big(0), grossPerformance: new Big(0), @@ -969,6 +962,10 @@ export abstract class PortfolioCalculator { const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentValues: { [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 feesAtStartDate = 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 .minus(totalInvestment) - .plus(grossPerformanceFromSells); + .plus(grossPerformanceFromSells) + .plus(dividends); const newGrossPerformanceWithCurrencyEffect = valueOfInvestmentWithCurrencyEffect .minus(totalInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellsWithCurrencyEffect); + .plus(grossPerformanceFromSellsWithCurrencyEffect) + .plus(dividendsWithCurrencyEffect); grossPerformance = newGrossPerformance; @@ -1226,6 +1235,7 @@ export abstract class PortfolioCalculator { newGrossPerformanceWithCurrencyEffect; if (activity.itemType === 'start') { + dividendsAtStartDateWithCurrencyEffect = dividendsWithCurrencyEffect; feesAtStartDate = fees; feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; grossPerformanceAtStartDate = grossPerformance; @@ -1240,6 +1250,11 @@ export abstract class PortfolioCalculator { currentValuesWithCurrencyEffect[activity.date] = valueOfInvestmentWithCurrencyEffect; + dividendValuesWithCurrencyEffect[activity.date] = + dividendsWithCurrencyEffect.minus( + dividendsAtStartDateWithCurrencyEffect + ); + netPerformanceValues[activity.date] = grossPerformance .minus(grossPerformanceAtStartDate) .minus(fees.minus(feesAtStartDate)); @@ -1313,6 +1328,7 @@ export abstract class PortfolioCalculator { return { currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, initialValue, investmentValuesAccumulated, investmentValuesAccumulatedWithCurrencyEffect, @@ -1422,8 +1438,7 @@ export abstract class PortfolioCalculator { const performancePercentagesByDate = this.calculatePerformancePercentagesForDateRange({ - historicalDataItems: historicalDataItemsOfDateRange, - holdings: this.snapshot.positions + historicalDataItems: historicalDataItemsOfDateRange }); const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => { @@ -1785,77 +1800,6 @@ export abstract class PortfolioCalculator { 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 private async initialize(attempt = 1) { const startTimeTotal = performance.now(); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy-with-dividend.spec.ts new file mode 100644 index 0000000000..46dbe493bf --- /dev/null +++ b/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 + }); + }); + }); +}); 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 ab9c24b4fb..18929a680c 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 @@ -122,8 +122,9 @@ describe('PortfolioCalculator', () => { { // The first activity of this asset profile is a fee, which is not an // investment activity. Thus the holding is not included in the - // holdings of the portfolio summary, and its dividend must not be - // included in the chart + // holdings of the portfolio summary. But its fee and its dividend are + // part of the net performance, and thus its dividend is part of the + // dividend of the chart ...activityDummyData, assetProfile: { ...assetProfileDummyData, @@ -167,8 +168,10 @@ describe('PortfolioCalculator', () => { const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - const dividendInBaseCurrency = - await portfolioCalculator.getDividendInBaseCurrency(); + const performanceByDateRange = await getPerformanceByDateRange({ + portfolioCalculator, + dateRanges: ['max'] + }); expect( portfolioSnapshot.positions.map(({ symbol }) => { @@ -176,12 +179,22 @@ describe('PortfolioCalculator', () => { }) ).toEqual(['MSFT']); - // The chart and the portfolio summary must show the same dividend - expect(dividendInBaseCurrency).toEqual(new Big('0.62')); + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + dividendInBaseCurrency: 5.62, + dividendInPercentageWithCurrencyEffect: 0.01882242615044544, + netPerformance: 18.87, + netPerformanceInPercentage: 0.06319914260834618 + }); - expect( - portfolioSnapshot.historicalData.at(-1).dividendInBaseCurrency - ).toEqual(0.62); + // The portfolio summary takes the dividend of the date range max + expect(performanceByDateRange).toMatchObject({ + max: { + dividendInBaseCurrency: 5.62, + dividendInPercentageWithCurrencyEffect: 0.01882242615044535, + netPerformance: 18.87, + netPerformanceInPercentage: 0.06319914260834586 + } + }); }); it('with GOOGL dividend without investment', async () => { @@ -221,8 +234,8 @@ describe('PortfolioCalculator', () => { unitPriceInAssetProfileCurrency: 0.62 }, { - // The holding has a dividend, but no average investment. Thus the - // dividend percentage stays 0, like the dividend yield + // The holding has a dividend, but no average investment. Its dividend + // is part of the net performance, and thus of the dividend percentage ...activityDummyData, assetProfile: { ...assetProfileDummyData, @@ -261,13 +274,108 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ dividendInBaseCurrency: 5.62, - dividendInPercentageWithCurrencyEffect: 0 + dividendInPercentageWithCurrencyEffect: 0.01882242615044544, + netPerformance: 19.87, + netPerformanceInPercentage: 0.06654832875611226 }); expect(performanceByDateRange).toMatchObject({ max: { 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 } }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 649542e325..0fdd0b3000 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -122,25 +122,25 @@ describe('PortfolioCalculator', () => { '0.001144362748184' ), fee: new Big('19'), - grossPerformance: new Big('33.25'), - grossPerformancePercentage: new Big('0.11136043941322258691'), + grossPerformance: new Big('33.87'), + grossPerformancePercentage: new Big('0.11343693482483756447'), grossPerformancePercentageWithCurrencyEffect: new Big( - '0.11136043941322258691' + '0.11343693482483756447' ), - grossPerformanceWithCurrencyEffect: new Big('33.25'), + grossPerformanceWithCurrencyEffect: new Big('33.87'), investment: new Big('298.58'), investmentWithCurrencyEffect: new Big('298.58'), marketPrice: 331.83, marketPriceInBaseCurrency: 331.83, - netPerformance: new Big('14.25'), - netPerformancePercentage: new Big('0.04772590260566682296'), + netPerformance: new Big('14.87'), + netPerformancePercentage: new Big('0.04980239801728180052'), netPerformancePercentageWithCurrencyEffectMap: { - max: new Big('0.04772590260566682296') + max: new Big('0.04980239801728180052') }, netPerformanceWithCurrencyEffectMap: { '1d': new Big('-5.39'), - '5y': new Big('14.25'), - max: new Big('14.25'), + '5y': new Big('14.87'), + max: new Big('14.87'), wtd: new Big('-5.39') }, quantity: new Big('1'), @@ -167,10 +167,10 @@ describe('PortfolioCalculator', () => { date: '2023-07-10', dividendInBaseCurrency: 0, dividendInPercentageWithCurrencyEffect: 0, - netPerformance: -5.390000000000001, - netPerformanceInPercentage: -0.015983630864124312, - netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124312, - netPerformanceWithCurrencyEffect: -5.390000000000001, + netPerformance: -5.390000000000002, + netPerformanceInPercentage: -0.015983630864124316, + netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124316, + netPerformanceWithCurrencyEffect: -5.390000000000002, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 }, @@ -178,10 +178,10 @@ describe('PortfolioCalculator', () => { date: '2023-07-10', dividendInBaseCurrency: 0.62, dividendInPercentageWithCurrencyEffect: 0.002076495411614967, - netPerformance: 14.25, - netPerformanceInPercentage: 0.04772590260566659, - netPerformanceInPercentageWithCurrencyEffect: 0.04772590260566659, - netPerformanceWithCurrencyEffect: 14.25, + netPerformance: 14.87, + netPerformanceInPercentage: 0.049802398017281556, + netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281556, + netPerformanceWithCurrencyEffect: 14.87, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 }, @@ -189,10 +189,10 @@ describe('PortfolioCalculator', () => { date: '2023-07-10', dividendInBaseCurrency: 0, dividendInPercentageWithCurrencyEffect: 0, - netPerformance: -7.68, - netPerformanceInPercentage: -0.022620835910577012, - netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577012, - netPerformanceWithCurrencyEffect: -7.68, + netPerformance: -7.6800000000000015, + netPerformanceInPercentage: -0.022620835910577015, + netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577015, + netPerformanceWithCurrencyEffect: -7.6800000000000015, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 } 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 c0b7337350..a1e41b53a2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -17,10 +17,7 @@ import { AssetProfileIdentifier, HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { - PortfolioSnapshot, - PortfolioSnapshotHolding -} from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -108,8 +105,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ? differenceInDays(new Date(), dateOfFirstActivity) : 0; - // Take the dividend from the same source as the portfolio summary, so - // that the response shows one dividend only const totalDividendInBaseCurrency = this.getDividendInBaseCurrencyOfHoldings(positions); @@ -168,15 +163,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentages({ - accumulatedValuesByDate, - holdings + accumulatedValuesByDate }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { - const hasDividendWithoutAverageInvestment = - this.hasDividendWithoutAverageInvestment(holdings); - const performancePercentagesByDate: { [date: string]: PerformancePercentages; } = {}; @@ -193,7 +183,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ] of Object.entries(accumulatedValuesByDate)) { performancePercentagesByDate[date] = { dividendInPercentageWithCurrencyEffect: - hasDividendWithoutAverageInvestment || totalAverageInvestmentValueWithCurrencyEffect.eq(0) ? 0 : totalDividendValueWithCurrencyEffect @@ -217,15 +206,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } protected calculatePerformancePercentagesForDateRange({ - historicalDataItems, - holdings + historicalDataItems }: { historicalDataItems: HistoricalDataItem[]; - holdings: PortfolioSnapshotHolding[]; }): { [date: string]: PerformancePercentages } { - const hasDividendWithoutAverageInvestment = - this.hasDividendWithoutAverageInvestment(holdings); - const averageInvestmentValues: number[] = []; const averageInvestmentValuesWithCurrencyEffect: number[] = []; let grossPerformanceAtStartDate: number; @@ -276,7 +260,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { performancePercentagesByDate[historicalDataItem.date] = { dividendInPercentageWithCurrencyEffect: - !hasDividendWithoutAverageInvestment && averageInvestmentValueWithCurrencyEffect > 0 ? historicalDataItem.dividendInBaseCurrency / averageInvestmentValueWithCurrencyEffect @@ -403,6 +386,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const { currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, initialValue, investmentValuesAccumulated, investmentValuesAccumulatedWithCurrencyEffect, @@ -736,6 +720,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { averageInvestmentValuesWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, dividendYieldPercent, dividendYieldPercentWithCurrencyEffect, grossPerformancePercentage, @@ -768,21 +753,4 @@ 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/holding-performance.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts index 0c35ab96be..61da17e71a 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-performance.interface.ts @@ -17,6 +17,9 @@ export interface HoldingPerformance { currentValuesWithCurrencyEffect: { [date: string]: Big; }; + dividendValuesWithCurrencyEffect: { + [date: string]: Big; + }; dividendYieldPercent: Big; dividendYieldPercentWithCurrencyEffect: Big; grossPerformance: Big; diff --git a/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts index 9e13fb70b4..06ae50005d 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-valuation.interface.ts @@ -5,6 +5,7 @@ import { HoldingValuationItem } from './holding-valuation-item.interface'; export interface HoldingValuation { currentValues: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big }; + dividendValuesWithCurrencyEffect: { [date: string]: Big }; initialValue?: Big; investmentValuesAccumulated: { [date: string]: Big }; investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 45b7477e8b..588bb6df17 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -524,6 +524,7 @@ export class PortfolioController { performanceInformation.chart = performanceInformation.chart.map( ({ date, + dividendInPercentageWithCurrencyEffect, netPerformanceInPercentage, netPerformanceInPercentageWithCurrencyEffect, netWorth, @@ -532,6 +533,7 @@ export class PortfolioController { }) => { return { date, + dividendInPercentageWithCurrencyEffect, netPerformanceInPercentage, netPerformanceInPercentageWithCurrencyEffect, netWorthInPercentage: diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 623f006eb7..c1dcbf8f53 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -865,7 +865,6 @@ describe('PortfolioService', () => { function createPortfolioCalculator() { return { - getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), @@ -891,6 +890,7 @@ describe('PortfolioService', () => { jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ performance: { currentValueInBaseCurrency: 3000, + dividendInBaseCurrency: 50, netPerformance: 500, netPerformancePercentage: 0.2, netPerformancePercentageWithCurrencyEffect: 0.2, @@ -926,6 +926,7 @@ describe('PortfolioService', () => { }); expect(summary.cash).toBe(1000); + expect(summary.dividendInBaseCurrency).toBe(50); expect(summary.emergencyFund.total).toBe(0); expect(summary.excludedAccountsAndActivities).toBe(0); expect(summary.totalAssetsInBaseCurrency).toBe(3000); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 17beb67793..00de213d08 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2140,6 +2140,7 @@ export class PortfolioService { const { currentValueInBaseCurrency, + dividendInBaseCurrency, dividendPercentageWithCurrencyEffect, netPerformance, netPerformancePercentage, @@ -2154,9 +2155,6 @@ export class PortfolioService { const dateOfFirstActivity = portfolioCalculator.getStartDate(); - const dividendInBaseCurrency = - await portfolioCalculator.getDividendInBaseCurrency(); - const fees = await portfolioCalculator.getFeesInBaseCurrency(); const interest = await portfolioCalculator.getInterestInBaseCurrency(); @@ -2237,6 +2235,7 @@ export class PortfolioService { cash, currentValueInBaseCurrency, dateOfFirstActivity, + dividendInBaseCurrency, dividendPercentageWithCurrencyEffect, excludedAccountsAndActivities, netPerformance, @@ -2248,7 +2247,6 @@ export class PortfolioService { activityCount: activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), // TODO: Remove the fallback to 0 with the next release, when each // cached portfolio snapshot contains the dividend yield dividendYieldPercent: dividendYieldPercent?.toNumber() ?? 0, diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 0a0beba910..3f84d81bf6 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -98,25 +98,20 @@ /> - @if (user?.settings?.isExperimentalFeatures) { -
-
Dividend
-
- @if (isNumber(summary?.dividendInBaseCurrency)) { - + - } - -
+
+
thereof Dividend
+
+
- } +
Fees
@@ -147,7 +142,7 @@ [locale]="locale" [precision]="precision" [unit]="displayedCurrency" - [value]="netPerformanceWithCurrencyEffect" + [value]="summary?.netPerformanceWithCurrencyEffect" />
@@ -160,7 +155,8 @@ ) { (ROAI) } @@ -173,7 +169,22 @@ [isLoading]="isLoading" [isPercent]="true" [locale]="locale" - [value]="netPerformancePercentageWithCurrencyEffect" + [value]="summary?.netPerformancePercentageWithCurrencyEffect" + /> +
+ +
+
+ thereof Dividend Performance +
+
+
@@ -506,20 +517,4 @@ /> - @if (!user?.settings?.isExperimentalFeatures) { -
-
Dividend
-
- -
-
- } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index 6b10d7d271..6dea74eb5d 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/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() { if (this.summary) { if ( diff --git a/apps/client/src/app/pages/faq/overview/faq-overview-page.html b/apps/client/src/app/pages/faq/overview/faq-overview-page.html index 77c23789e6..66b416fbab 100644 --- a/apps/client/src/app/pages/faq/overview/faq-overview-page.html +++ b/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 over time. ROAI aims to provide a more insightful view of investment performance than simpler approaches, especially when contributions are - made over time. Dividends are not part of the - calculation. diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index a08c9d9d52..5629965006 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -133,36 +133,6 @@ export class GfAnalysisPageComponent implements OnInit { 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() { if (!this.savingsRatePerMonth) { return undefined; @@ -363,6 +333,7 @@ export class GfAnalysisPageComponent implements OnInit { index, { date, + dividendInPercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect, valueInPercentage, @@ -393,7 +364,9 @@ export class GfAnalysisPageComponent implements OnInit { if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) { this.performanceDataItemsInPercentage.push({ date, - value: netPerformanceInPercentageWithCurrencyEffect + value: + netPerformanceInPercentageWithCurrencyEffect - + (dividendInPercentageWithCurrencyEffect ?? 0) }); } } 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 e05d29c414..96dfe24c45 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -187,45 +187,6 @@ /> - @if (user?.settings?.isExperimentalFeatures) { -
-
- Absolute Dividend Performance - @if (user?.subscription?.type === 'Basic') { - - } -
-
- -
-
-
-
- Dividend Performance -
-
- -
-
- }
@@ -299,7 +260,8 @@ ) { (ROAI) } @@ -312,7 +274,45 @@ [isLoading]="isLoadingInvestmentChart" [isPercent]="true" [locale]="user?.settings?.locale" - [value]="netPerformancePercentageWithCurrencyEffect" + [value]=" + performance?.netPerformancePercentageWithCurrencyEffect + " + /> + + +
+
+ thereof Dividend + @if (user?.subscription?.type === 'Basic') { + + } +
+
+ +
+
+
+
+ thereof Dividend Performance +
+
+