From 89da484bb7f4b924150c570e0f09808368f083f5 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:46:23 +0200 Subject: [PATCH] Task/move percentage formulas of portfolio calculator to method class (#7919) Move percentage formulas of portfolio calculator to method class --- .../calculator/mwr/portfolio-calculator.ts | 16 +- .../calculator/portfolio-calculator.ts | 150 +++++-------- .../roai/portfolio-calculator-cash.spec.ts | 2 + ...-calculator-msft-buy-with-dividend.spec.ts | 6 + .../calculator/roai/portfolio-calculator.ts | 207 ++++++++++++++++-- .../calculator/roi/portfolio-calculator.ts | 16 +- .../calculator/twr/portfolio-calculator.ts | 16 +- .../accumulated-values.interface.ts | 15 ++ .../holding-performance.interface.ts | 2 + .../src/app/portfolio/portfolio.service.ts | 55 +++-- .../types/net-performance-percentages.type.ts | 9 + .../interfaces/portfolio-summary.interface.ts | 2 + .../lib/models/portfolio-snapshot-holding.ts | 8 + .../src/lib/models/portfolio-snapshot.ts | 8 + 14 files changed, 380 insertions(+), 132 deletions(-) create mode 100644 apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts create mode 100644 apps/api/src/app/portfolio/types/net-performance-percentages.type.ts diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index 2d249cc487..e9f24ccd9b 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -1,12 +1,24 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; -import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; +import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; export class MwrPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance([]: PortfolioCalculatorHolding[]): PortfolioSnapshot { + protected calculateNetPerformancePercentages(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateNetPerformancePercentagesForDateRange(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateOverallPerformance(): PortfolioSnapshot { throw new Error('Method not implemented.'); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 4c36598396..debfa7d23f 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,5 +1,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; +import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingValuationItem } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation-item.interface'; import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface'; @@ -9,6 +10,7 @@ import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interf import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; +import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; @@ -41,7 +43,10 @@ import { InvestmentItem, ResponseError } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { + PortfolioSnapshot, + PortfolioSnapshotHolding +} from '@ghostfolio/common/models'; import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -67,7 +72,7 @@ import { startOfYear, subDays } from 'date-fns'; -import { groupBy, isNumber, sortBy, sum, uniqBy } from 'lodash'; +import { groupBy, sortBy, uniqBy } from 'lodash'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; @@ -213,6 +218,18 @@ export abstract class PortfolioCalculator { this.snapshotPromise.catch(() => undefined); } + protected abstract calculateNetPerformancePercentages({ + accumulatedValuesByDate + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }): { [date: string]: NetPerformancePercentages }; + + protected abstract calculateNetPerformancePercentagesForDateRange({ + historicalDataItems + }: { + historicalDataItems: HistoricalDataItem[]; + }): { [date: string]: NetPerformancePercentages }; + protected abstract calculateOverallPerformance( positions: PortfolioCalculatorHolding[] ): PortfolioSnapshot; @@ -230,6 +247,8 @@ export abstract class PortfolioCalculator { activitiesCount: 0, createdAt: new Date(), currentValueInBaseCurrency: new Big(0), + dividendYieldPercent: new Big(0), + dividendYieldPercentWithCurrencyEffect: new Big(0), errors: [], hasErrors: false, historicalData: [], @@ -353,19 +372,7 @@ export abstract class PortfolioCalculator { const positions: PortfolioCalculatorHolding[] = []; const accumulatedValuesByDate: { - [date: string]: { - investmentValueWithCurrencyEffect: Big; - totalAverageInvestmentValue: Big; - totalAverageInvestmentValueWithCurrencyEffect: Big; - totalCashValueWithCurrencyEffect: Big; - totalCurrentValue: Big; - totalCurrentValueWithCurrencyEffect: Big; - totalInvestmentValue: Big; - totalInvestmentValueWithCurrencyEffect: Big; - totalNetPerformanceValue: Big; - totalNetPerformanceValueWithCurrencyEffect: Big; - totalNetWorthValueWithCurrencyEffect: Big; - }; + [date: string]: AccumulatedValues; } = {}; const valuesByAssetProfileIdentifier: { @@ -409,6 +416,8 @@ export abstract class PortfolioCalculator { averageInvestmentWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, grossPerformance, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, @@ -477,6 +486,8 @@ export abstract class PortfolioCalculator { positions.push({ averageInvestment, averageInvestmentWithCurrencyEffect, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, activitiesCount: item.activitiesCount, averagePrice: item.averagePrice, currency: item.currency, @@ -647,13 +658,14 @@ export abstract class PortfolioCalculator { } } + const netPerformancePercentagesByDate = + this.calculateNetPerformancePercentages({ accumulatedValuesByDate }); + const historicalData: HistoricalDataItem[] = Object.entries( accumulatedValuesByDate ).map(([date, values]) => { const { investmentValueWithCurrencyEffect, - totalAverageInvestmentValue, - totalAverageInvestmentValueWithCurrencyEffect, totalCashValueWithCurrencyEffect, totalCurrentValue, totalCurrentValueWithCurrencyEffect, @@ -664,21 +676,9 @@ export abstract class PortfolioCalculator { totalNetWorthValueWithCurrencyEffect } = values; - const netPerformanceInPercentage = totalAverageInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue.div(totalAverageInvestmentValue).toNumber(); - - const netPerformanceInPercentageWithCurrencyEffect = - totalAverageInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalAverageInvestmentValueWithCurrencyEffect) - .toNumber(); - return { + ...netPerformancePercentagesByDate[date], date, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, investmentValueWithCurrencyEffect: investmentValueWithCurrencyEffect.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(), @@ -857,8 +857,14 @@ export abstract class PortfolioCalculator { public async getDividendInBaseCurrency() { await this.snapshotPromise; + return this.getDividendInBaseCurrencyOfHoldings(this.snapshot.positions); + } + + protected getDividendInBaseCurrencyOfHoldings( + holdings: PortfolioSnapshotHolding[] + ) { return getSum( - this.snapshot.positions.map(({ dividendInBaseCurrency }) => { + holdings.map(({ dividendInBaseCurrency }) => { return dividendInBaseCurrency; }) ); @@ -872,6 +878,8 @@ export abstract class PortfolioCalculator { averageInvestmentWithCurrencyEffect: new Big(0), currentValues: {}, currentValuesWithCurrencyEffect: {}, + dividendYieldPercent: new Big(0), + dividendYieldPercentWithCurrencyEffect: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -1342,12 +1350,8 @@ export abstract class PortfolioCalculator { const { historicalData } = this.snapshot; - const chart: HistoricalDataItem[] = []; + const historicalDataItemsOfDateRange: HistoricalDataItem[] = []; - const averageInvestmentValues: number[] = []; - const averageInvestmentValuesWithCurrencyEffect: number[] = []; - let grossPerformanceAtStartDate: number; - let grossPerformanceWithCurrencyEffectAtStartDate: number; let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; @@ -1355,72 +1359,38 @@ export abstract class PortfolioCalculator { const date = resetHours(parseDate(historicalDataItem.date)); if (!isBefore(date, start) && !isAfter(date, end)) { - if (!isNumber(netPerformanceAtStartDate)) { - grossPerformanceAtStartDate = - historicalDataItem.value - historicalDataItem.totalInvestment; - - grossPerformanceWithCurrencyEffectAtStartDate = - historicalDataItem.valueWithCurrencyEffect - - historicalDataItem.totalInvestmentValueWithCurrencyEffect; - + // Take the values at the start date from the first day of the date + // range + if (historicalDataItemsOfDateRange.length === 0) { netPerformanceAtStartDate = historicalDataItem.netPerformance; netPerformanceWithCurrencyEffectAtStartDate = historicalDataItem.netPerformanceWithCurrencyEffect; } - const netPerformanceSinceStartDate = - historicalDataItem.netPerformance - netPerformanceAtStartDate; - - const netPerformanceWithCurrencyEffectSinceStartDate = - historicalDataItem.netPerformanceWithCurrencyEffect - - netPerformanceWithCurrencyEffectAtStartDate; - - // Add the gross performance at the start date of the range to the - // investment of each day. Thus the range starts with the value of its - // first day, and subsequent buy and sell activities stay included. - if (historicalDataItem.totalInvestment > 0) { - averageInvestmentValues.push( - historicalDataItem.totalInvestment + grossPerformanceAtStartDate - ); - } - - if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) { - averageInvestmentValuesWithCurrencyEffect.push( - historicalDataItem.totalInvestmentValueWithCurrencyEffect + - grossPerformanceWithCurrencyEffectAtStartDate - ); - } - - const averageInvestmentValue = - averageInvestmentValues.length > 0 - ? sum(averageInvestmentValues) / averageInvestmentValues.length - : 0; - - const averageInvestmentValueWithCurrencyEffect = - averageInvestmentValuesWithCurrencyEffect.length > 0 - ? sum(averageInvestmentValuesWithCurrencyEffect) / - averageInvestmentValuesWithCurrencyEffect.length - : 0; - - chart.push({ + historicalDataItemsOfDateRange.push({ ...historicalDataItem, - netPerformance: netPerformanceSinceStartDate, + netPerformance: + historicalDataItem.netPerformance - netPerformanceAtStartDate, netPerformanceWithCurrencyEffect: - netPerformanceWithCurrencyEffectSinceStartDate, - netPerformanceInPercentage: - averageInvestmentValue > 0 - ? netPerformanceSinceStartDate / averageInvestmentValue - : 0, - netPerformanceInPercentageWithCurrencyEffect: - averageInvestmentValueWithCurrencyEffect > 0 - ? netPerformanceWithCurrencyEffectSinceStartDate / - averageInvestmentValueWithCurrencyEffect - : 0 + historicalDataItem.netPerformanceWithCurrencyEffect - + netPerformanceWithCurrencyEffectAtStartDate }); } } + const netPerformancePercentagesByDate = + this.calculateNetPerformancePercentagesForDateRange({ + historicalDataItems: historicalDataItemsOfDateRange + }); + + const chart = historicalDataItemsOfDateRange.map((historicalDataItem) => { + return { + ...historicalDataItem, + ...netPerformancePercentagesByDate[historicalDataItem.date] + }; + }); + return { chart }; } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 1ced004e42..23c806483c 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -262,6 +262,8 @@ describe('PortfolioCalculator', () => { dateOfFirstActivity: '2023-12-31', dividend: new Big(0), dividendInBaseCurrency: new Big(0), + dividendYieldPercent: new Big(0), + dividendYieldPercentWithCurrencyEffect: new Big(0), fee: new Big(0), feeInBaseCurrency: new Big(0), grossPerformance: new Big(0), 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 87e13e75c9..18dc5ab246 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 @@ -137,6 +137,8 @@ describe('PortfolioCalculator', () => { }); expect(portfolioSnapshot).toMatchObject({ + dividendYieldPercent: new Big('0.001144362748184'), + dividendYieldPercentWithCurrencyEffect: new Big('0.001144362748184'), errors: [], hasErrors: false, positions: [ @@ -148,6 +150,10 @@ describe('PortfolioCalculator', () => { dateOfFirstActivity: '2021-09-16', dividend: new Big('0.62'), dividendInBaseCurrency: new Big('0.62'), + dividendYieldPercent: new Big('0.001144362748184'), + dividendYieldPercentWithCurrencyEffect: new Big( + '0.001144362748184' + ), fee: new Big('19'), grossPerformance: new Big('33.25'), grossPerformancePercentage: new Big('0.11136043941322258691'), 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 a830b762c0..cd4f474ec0 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -1,14 +1,22 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; -import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; import { DATE_FORMAT, getAssetProfileIdentifier, parseDate } from '@ghostfolio/common/helper'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -21,16 +29,119 @@ import { isBefore, isThisYear } from 'date-fns'; +import { sum } from 'lodash'; export class RoaiPortfolioCalculator extends PortfolioCalculator { + protected calculateNetPerformancePercentages({ + accumulatedValuesByDate + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }): { [date: string]: NetPerformancePercentages } { + const netPerformancePercentagesByDate: { + [date: string]: NetPerformancePercentages; + } = {}; + + for (const [ + date, + { + totalAverageInvestmentValue, + totalAverageInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + } + ] of Object.entries(accumulatedValuesByDate)) { + netPerformancePercentagesByDate[date] = { + netPerformanceInPercentage: totalAverageInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalAverageInvestmentValue) + .toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + totalAverageInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalAverageInvestmentValueWithCurrencyEffect) + .toNumber() + }; + } + + return netPerformancePercentagesByDate; + } + + protected calculateNetPerformancePercentagesForDateRange({ + historicalDataItems + }: { + historicalDataItems: HistoricalDataItem[]; + }): { [date: string]: NetPerformancePercentages } { + const averageInvestmentValues: number[] = []; + const averageInvestmentValuesWithCurrencyEffect: number[] = []; + let grossPerformanceAtStartDate: number; + let grossPerformanceWithCurrencyEffectAtStartDate: number; + + const netPerformancePercentagesByDate: { + [date: string]: NetPerformancePercentages; + } = {}; + + for (const [index, historicalDataItem] of historicalDataItems.entries()) { + // Take the values at the start date from the first day of the date + // range + if (index === 0) { + grossPerformanceAtStartDate = + historicalDataItem.value - historicalDataItem.totalInvestment; + + grossPerformanceWithCurrencyEffectAtStartDate = + historicalDataItem.valueWithCurrencyEffect - + historicalDataItem.totalInvestmentValueWithCurrencyEffect; + } + + // Add the gross performance at the start date of the range to the + // investment of each day. Thus the range starts with the value of its + // first day, and subsequent buy and sell activities stay included. + if (historicalDataItem.totalInvestment > 0) { + averageInvestmentValues.push( + historicalDataItem.totalInvestment + grossPerformanceAtStartDate + ); + } + + if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) { + averageInvestmentValuesWithCurrencyEffect.push( + historicalDataItem.totalInvestmentValueWithCurrencyEffect + + grossPerformanceWithCurrencyEffectAtStartDate + ); + } + + const averageInvestmentValue = + averageInvestmentValues.length > 0 + ? sum(averageInvestmentValues) / averageInvestmentValues.length + : 0; + + const averageInvestmentValueWithCurrencyEffect = + averageInvestmentValuesWithCurrencyEffect.length > 0 + ? sum(averageInvestmentValuesWithCurrencyEffect) / + averageInvestmentValuesWithCurrencyEffect.length + : 0; + + netPerformancePercentagesByDate[historicalDataItem.date] = { + netPerformanceInPercentage: + averageInvestmentValue > 0 + ? historicalDataItem.netPerformance / averageInvestmentValue + : 0, + netPerformanceInPercentageWithCurrencyEffect: + averageInvestmentValueWithCurrencyEffect > 0 + ? historicalDataItem.netPerformanceWithCurrencyEffect / + averageInvestmentValueWithCurrencyEffect + : 0 + }; + } + + return netPerformancePercentagesByDate; + } + protected calculateOverallPerformance( positions: PortfolioCalculatorHolding[] ): PortfolioSnapshot { let currentValueInBaseCurrency = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; - let netPerformance = new Big(0); let totalAverageInvestment = new Big(0); let totalAverageInvestmentWithCurrencyEffect = new Big(0); let totalFeesWithCurrencyEffect = new Big(0); @@ -68,18 +179,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { hasErrors = true; } - if (currentPosition.grossPerformance) { - grossPerformance = grossPerformance.plus( - currentPosition.grossPerformance - ); - - grossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.plus( - currentPosition.grossPerformanceWithCurrencyEffect - ); - - netPerformance = netPerformance.plus(currentPosition.netPerformance); - } else if (!currentPosition.quantity.eq(0)) { + if ( + !currentPosition.grossPerformance && + !currentPosition.quantity.eq(0) + ) { hasErrors = true; } @@ -101,8 +204,54 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } + const dateOfFirstActivity = this.getStartDate(); + + const daysInMarket = dateOfFirstActivity + ? 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); + + // A holding without a market price, and a holding which is excluded from + // the performance, gives a dividend but no average investment. Such a + // holding makes the dividend yield too high. Therefore the dividend yield + // stays 0 in this case. + const hasDividendWithoutAverageInvestment = positions.some( + ({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => { + return ( + !dividendInBaseCurrency.eq(0) && + (!includeInPerformance || averageInvestment.eq(0)) + ); + } + ); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: + hasDividendWithoutAverageInvestment || totalAverageInvestment.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(totalAverageInvestment) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: + hasDividendWithoutAverageInvestment || + totalAverageInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div( + totalAverageInvestmentWithCurrencyEffect + ) + }); + return { currentValueInBaseCurrency, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, hasErrors, positions, totalFeesWithCurrencyEffect, @@ -401,6 +550,28 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ? totalNetPerformance.div(averageInvestmentBetweenStartAndEndDate) : new Big(0); + const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: averageInvestmentBetweenStartAndEndDate.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div( + averageInvestmentBetweenStartAndEndDate + ) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: + averageInvestmentBetweenStartAndEndDateWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div( + averageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + }); + const netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big; } = {}; @@ -538,6 +709,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { averageInvestmentValuesWithCurrencyEffect, currentValues, currentValuesWithCurrencyEffect, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, investmentValuesAccumulated, diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts index d31848a2be..5829585ed7 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,12 +1,24 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; -import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; +import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance([]: PortfolioCalculatorHolding[]): PortfolioSnapshot { + protected calculateNetPerformancePercentages(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateNetPerformancePercentagesForDateRange(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateOverallPerformance(): PortfolioSnapshot { throw new Error('Method not implemented.'); } diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index b577070c34..c3877a347c 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -1,12 +1,24 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; -import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; +import { NetPerformancePercentages } from '@ghostfolio/api/app/portfolio/types/net-performance-percentages.type'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; export class TwrPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance([]: PortfolioCalculatorHolding[]): PortfolioSnapshot { + protected calculateNetPerformancePercentages(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateNetPerformancePercentagesForDateRange(): { + [date: string]: NetPerformancePercentages; + } { + throw new Error('Method not implemented.'); + } + + protected calculateOverallPerformance(): PortfolioSnapshot { throw new Error('Method not implemented.'); } diff --git a/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts new file mode 100644 index 0000000000..919dccc227 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/accumulated-values.interface.ts @@ -0,0 +1,15 @@ +import { Big } from 'big.js'; + +export interface AccumulatedValues { + investmentValueWithCurrencyEffect: Big; + totalAverageInvestmentValue: Big; + totalAverageInvestmentValueWithCurrencyEffect: Big; + totalCashValueWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalNetWorthValueWithCurrencyEffect: Big; +} 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 c94486844a..0c35ab96be 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,8 @@ export interface HoldingPerformance { currentValuesWithCurrencyEffect: { [date: string]: Big; }; + dividendYieldPercent: Big; + dividendYieldPercentWithCurrencyEffect: Big; grossPerformance: Big; grossPerformancePercentage: Big; grossPerformancePercentageWithCurrencyEffect: Big; diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index de3eecbcaa..a9a1a95faf 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -990,6 +990,9 @@ export class PortfolioService { currency, dateOfFirstActivity, dividendInBaseCurrency, + dividendYieldPercent: dividendYieldPercentOfSnapshot, + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffectOfSnapshot, feeInBaseCurrency, grossPerformance, grossPerformancePercentage, @@ -1005,34 +1008,41 @@ export class PortfolioService { tags } = holding; - const activitiesOfHolding = activities.filter((activity) => { - return ( - activity.assetProfile.dataSource === dataSource && - activity.assetProfile.symbol === symbol - ); - }); + // TODO: Remove the block below with the next release, when each cached + // portfolio snapshot contains the dividend yield. Then take + // dividendYieldPercent and dividendYieldPercentWithCurrencyEffect + // directly from the holding and remove averageInvestment and + // averageInvestmentWithCurrencyEffect from the properties above + const daysInMarket = differenceInDays( + new Date(), + parseDate(dateOfFirstActivity) + ); - const dividendYieldPercent = getAnnualizedPerformancePercent({ - daysInMarket: differenceInDays( - new Date(), - parseDate(dateOfFirstActivity) - ), - netPerformancePercentage: averageInvestment.eq(0) - ? new Big(0) - : dividendInBaseCurrency.div(averageInvestment) - }); + const dividendYieldPercent = + dividendYieldPercentOfSnapshot ?? + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: averageInvestment.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(averageInvestment) + }); const dividendYieldPercentWithCurrencyEffect = + dividendYieldPercentWithCurrencyEffectOfSnapshot ?? getAnnualizedPerformancePercent({ - daysInMarket: differenceInDays( - new Date(), - parseDate(dateOfFirstActivity) - ), + daysInMarket, netPerformancePercentage: averageInvestmentWithCurrencyEffect.eq(0) ? new Big(0) : dividendInBaseCurrency.div(averageInvestmentWithCurrencyEffect) }); + const activitiesOfHolding = activities.filter((activity) => { + return ( + activity.assetProfile.dataSource === dataSource && + activity.assetProfile.symbol === symbol + ); + }); + const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol }], 'day', @@ -2097,6 +2107,8 @@ export class PortfolioService { } const { + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, totalCashInBaseCurrency, totalInvestment, totalInvestmentWithCurrencyEffect, @@ -2219,6 +2231,11 @@ export class PortfolioService { 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, + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffect?.toNumber() ?? 0, emergencyFund: { assets: emergencyFundHoldingsValueInBaseCurrency, cash: totalEmergencyFund diff --git a/apps/api/src/app/portfolio/types/net-performance-percentages.type.ts b/apps/api/src/app/portfolio/types/net-performance-percentages.type.ts new file mode 100644 index 0000000000..288f44631d --- /dev/null +++ b/apps/api/src/app/portfolio/types/net-performance-percentages.type.ts @@ -0,0 +1,9 @@ +import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; + +export type NetPerformancePercentages = Required< + Pick< + HistoricalDataItem, + | 'netPerformanceInPercentage' + | 'netPerformanceInPercentageWithCurrencyEffect' + > +>; diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index b1b7e938bc..4e6402d162 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -8,6 +8,8 @@ export interface PortfolioSummary extends PortfolioPerformance { cash: number; dateOfFirstActivity?: Date; dividendInBaseCurrency: number; + dividendYieldPercent: number; + dividendYieldPercentWithCurrencyEffect: number; emergencyFund: { assets: number; cash: number; diff --git a/libs/common/src/lib/models/portfolio-snapshot-holding.ts b/libs/common/src/lib/models/portfolio-snapshot-holding.ts index 7ed40d6b8b..21d375fd4a 100644 --- a/libs/common/src/lib/models/portfolio-snapshot-holding.ts +++ b/libs/common/src/lib/models/portfolio-snapshot-holding.ts @@ -60,6 +60,14 @@ export class PortfolioSnapshotHolding { @Type(() => Big) dividendInBaseCurrency: Big; + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividendYieldPercent: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividendYieldPercentWithCurrencyEffect: Big; + @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big) fee: Big; diff --git a/libs/common/src/lib/models/portfolio-snapshot.ts b/libs/common/src/lib/models/portfolio-snapshot.ts index 802b470e43..83beb0d202 100644 --- a/libs/common/src/lib/models/portfolio-snapshot.ts +++ b/libs/common/src/lib/models/portfolio-snapshot.ts @@ -17,6 +17,14 @@ export class PortfolioSnapshot { @Type(() => Big) currentValueInBaseCurrency: Big; + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividendYieldPercent: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividendYieldPercentWithCurrencyEffect: Big; + errors: AssetProfileIdentifier[]; hasErrors: boolean;