diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d172652a83..0b2207b1fb 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,11 +1,14 @@ 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 { DateRangeInterval } from '@ghostfolio/api/app/portfolio/interfaces/date-range-interval.interface'; import { HoldingBalance } from '@ghostfolio/api/app/portfolio/interfaces/holding-balance.interface'; import { HoldingBalancesAtDate } from '@ghostfolio/api/app/portfolio/interfaces/holding-balances-at-date.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'; +import { InvestedCapital } from '@ghostfolio/api/app/portfolio/interfaces/invested-capital.interface'; +import { MethodIndependentHoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/method-independent-holding-performance.interface'; import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; @@ -18,7 +21,10 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; import { INVESTMENT_ACTIVITY_TYPES, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, @@ -47,7 +53,7 @@ import { PortfolioSnapshot, PortfolioSnapshotHolding } from '@ghostfolio/common/models'; -import { GroupBy } from '@ghostfolio/common/types'; +import { DateRange, GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Logger } from '@nestjs/common'; @@ -66,13 +72,14 @@ import { isBefore, isFuture, isPast, + isThisYear, isWithinInterval, min, startOfDay, startOfYear, subDays } from 'date-fns'; -import { groupBy, sortBy, uniqBy } from 'lodash-es'; +import { groupBy, mapValues, sortBy, uniqBy } from 'lodash-es'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; @@ -218,9 +225,13 @@ export abstract class PortfolioCalculator { this.snapshotPromise.catch(() => undefined); } - protected abstract calculateOverallPerformance( - positions: PortfolioCalculatorHolding[] - ): PortfolioSnapshot; + protected abstract calculateOverallPerformance({ + accumulatedValuesByDate, + positions + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + positions: PortfolioCalculatorHolding[]; + }): PortfolioSnapshot; protected abstract calculatePerformancePercentages({ accumulatedValuesByDate @@ -727,7 +738,10 @@ export abstract class PortfolioCalculator { }; }); - const overall = this.calculateOverallPerformance(positions); + const overall = this.calculateOverallPerformance({ + accumulatedValuesByDate, + positions + }); return { ...overall, @@ -965,6 +979,80 @@ export abstract class PortfolioCalculator { start: Date; } & AssetProfileIdentifier): HoldingPerformance; + /** + * Adds the values which depend on the invested capital of the performance + * calculation type to the holding performance: the percentages and the + * dividend yield. The average investment fields keep their names and hold + * the invested capital of each type. + */ + protected getHoldingPerformanceWithInvestedCapital({ + daysInMarket, + holdingPerformance, + investedCapital, + investedCapitalByDateRange, + investedCapitalValues = {}, + investedCapitalValuesWithCurrencyEffect = {}, + investedCapitalWithCurrencyEffect + }: { + daysInMarket: number; + holdingPerformance: HoldingPerformance; + investedCapitalByDateRange: { [key: DateRange]: Big }; + investedCapitalValues?: { [date: string]: Big }; + investedCapitalValuesWithCurrencyEffect?: { [date: string]: Big }; + } & InvestedCapital): HoldingPerformance { + const { + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + netPerformanceWithCurrencyEffectMap, + totalDividendInBaseCurrency + } = holdingPerformance; + + const netPerformancePercentageWithCurrencyEffectMap = mapValues( + netPerformanceWithCurrencyEffectMap, + (netPerformanceOfDateRange, dateRange) => { + const investedCapitalOfDateRange = + investedCapitalByDateRange[dateRange] ?? new Big(0); + + return investedCapitalOfDateRange.gt(0) + ? netPerformanceOfDateRange.div(investedCapitalOfDateRange) + : new Big(0); + } + ); + + return { + ...holdingPerformance, + netPerformancePercentageWithCurrencyEffectMap, + averageInvestment: investedCapital, + averageInvestmentValues: investedCapitalValues, + averageInvestmentValuesWithCurrencyEffect: + investedCapitalValuesWithCurrencyEffect, + averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect, + dividendYieldPercent: this.getDividendYieldPercent({ + daysInMarket, + investedCapital, + dividendInBaseCurrency: totalDividendInBaseCurrency + }), + dividendYieldPercentWithCurrencyEffect: this.getDividendYieldPercent({ + daysInMarket, + dividendInBaseCurrency: totalDividendInBaseCurrency, + investedCapital: investedCapitalWithCurrencyEffect + }), + grossPerformancePercentage: investedCapital.gt(0) + ? grossPerformance.div(investedCapital) + : new Big(0), + grossPerformancePercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.gt(0) + ? grossPerformanceWithCurrencyEffect.div( + investedCapitalWithCurrencyEffect + ) + : new Big(0), + netPerformancePercentage: investedCapital.gt(0) + ? netPerformance.div(investedCapital) + : new Big(0) + }; + } + protected getHoldingValuation({ activities, exchangeRates, @@ -1406,6 +1494,354 @@ export abstract class PortfolioCalculator { return this.snapshot.totalLiabilitiesWithCurrencyEffect; } + /** + * Returns the performance of a holding without the values which depend on + * the performance calculation type: the average investment, the percentages + * and the dividend yield. The valuation is missing if the holding has no + * activities or no market price. + */ + protected getMethodIndependentHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDates: string[]; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [assetProfileIdentifier: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): MethodIndependentHoldingPerformance { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + let activities: PortfolioCalculatorActivityItem[] = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; + + const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; + + if (activities.length <= 0) { + return { holdingPerformance: this.getEmptyHoldingPerformance() }; + } + + // The dividends, the interest and the liabilities are derived from the + // activities only. Accumulate them upfront so that they survive the bail + // out for symbols without a market price below. + const { + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency + } = this.getTotalsFromActivities({ activities, exchangeRates }); + + const dateOfFirstActivity = parseDate(activities[0].date); + + const endDateString = format(end, DATE_FORMAT); + const startDateString = format(start, DATE_FORMAT); + + const unitPriceAtStartDate = + marketSymbolMap[startDateString]?.[assetProfileIdentifier]; + + const unitPriceAtEndDate = this.getUnitPriceAtEndDate({ + activities, + dataSource, + isCash, + marketPriceAtEndDate: + marketSymbolMap[endDateString]?.[assetProfileIdentifier] + }); + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start)) + ) { + // A missing market price can only affect the quantity which is held. The + // dividends, the interest and the liabilities do not hold any quantity + // and are therefore not in error. + const hasActivitiesWithQuantity = activities.some(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }); + + return { + holdingPerformance: { + ...this.getEmptyHoldingPerformance(), + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency, + hasErrors: hasActivitiesWithQuantity + } + }; + } + + activities = this.getActivitiesWithMarketPrices({ + activities, + chartDates, + endDateString, + marketSymbolMap, + startDateString, + unitPriceAtEndDate, + unitPriceAtStartDate, + assetProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + } + }); + + const holdingValuation = this.getHoldingValuation({ + activities, + exchangeRates, + unitPriceAtStartDate + }); + + const { + currentValues, + currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, + initialValue, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + items, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect + } = holdingValuation; + + const indexOfStartActivity = items.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + + const indexOfEndActivity = items.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + const { + fees: feesAtStartDate, + grossPerformance: grossPerformanceAtStartDate, + grossPerformanceWithCurrencyEffect: + grossPerformanceAtStartDateWithCurrencyEffect + } = items[indexOfStartActivity]; + + const { + fees, + grossPerformance, + grossPerformanceWithCurrencyEffect, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + quantity: totalQuantity + } = items[indexOfEndActivity]; + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + + const totalNetPerformance = totalGrossPerformance.minus( + fees.minus(feesAtStartDate) + ); + + const dateRanges = ( + [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end, start }) + .filter((date) => { + return !isThisYear(date); + }) + .map((date) => { + return format(date, 'yyyy'); + }) + ] as DateRange[] + ).map((dateRange): DateRangeInterval => { + const dateInterval = getIntervalFromDateRange({ dateRange }); + let startDateOfDateRange = dateInterval.startDate; + + if (isBefore(startDateOfDateRange, start)) { + startDateOfDateRange = start; + } + + return { + dateRange, + endDateString: format(dateInterval.endDate, DATE_FORMAT), + startDateString: format(startDateOfDateRange, DATE_FORMAT) + }; + }); + + const netPerformanceWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + for (const { + dateRange, + endDateString: rangeEndDateString, + startDateString: rangeStartDateString + } of dateRanges) { + netPerformanceWithCurrencyEffectMap[dateRange] = + netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0); + } + + return { + holdingPerformance: { + ...this.getEmptyHoldingPerformance(), + currentValues, + currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilitiesInBaseCurrency, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: + totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance + }, + valuation: { + ...holdingValuation, + activities, + dateRanges, + indexOfEndActivity, + indexOfStartActivity, + unitPriceAtEndDate, + daysInMarket: differenceInDays(new Date(), dateOfFirstActivity) + } + }; + } + + /** + * Returns the overall performance of the holdings. The invested capital of + * the performance calculation type is the denominator of the dividend yield. + */ + protected getOverallPerformance({ + investedCapital, + investedCapitalWithCurrencyEffect, + positions + }: { + positions: PortfolioCalculatorHolding[]; + } & InvestedCapital): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let hasErrors = false; + let totalFeesWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + + for (const { + feeInBaseCurrency, + grossPerformance, + includeInPerformance, + investment, + investmentWithCurrencyEffect, + quantity, + valueInBaseCurrency + } of positions) { + currentValueInBaseCurrency = + currentValueInBaseCurrency.plus(valueInBaseCurrency); + + if (!includeInPerformance) { + continue; + } + + totalFeesWithCurrencyEffect = + totalFeesWithCurrencyEffect.plus(feeInBaseCurrency); + + totalInvestment = totalInvestment.plus(investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus(investmentWithCurrencyEffect); + + if (!grossPerformance && !quantity.eq(0)) { + hasErrors = true; + } + } + + const dateOfFirstActivity = this.getStartDate(); + + const daysInMarket = dateOfFirstActivity + ? differenceInDays(new Date(), dateOfFirstActivity) + : 0; + + 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 invested capital. Such a + // holding makes the dividend yield too high. Therefore the dividend yield + // stays 0 in this case. + const hasDividendWithoutInvestedCapital = positions.some( + ({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => { + return ( + !dividendInBaseCurrency.eq(0) && + (!includeInPerformance || averageInvestment.eq(0)) + ); + } + ); + + return { + currentValueInBaseCurrency, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + createdAt: new Date(), + dividendYieldPercent: this.getDividendYieldPercent({ + daysInMarket, + dividendInBaseCurrency: totalDividendInBaseCurrency, + investedCapital: hasDividendWithoutInvestedCapital + ? new Big(0) + : investedCapital + }), + dividendYieldPercentWithCurrencyEffect: this.getDividendYieldPercent({ + daysInMarket, + dividendInBaseCurrency: totalDividendInBaseCurrency, + investedCapital: hasDividendWithoutInvestedCapital + ? new Big(0) + : investedCapitalWithCurrencyEffect + }), + errors: [], + historicalData: [], + totalCashInBaseCurrency: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }; + } + public async getPerformance({ end, start }: { end: Date; start: Date }) { await this.snapshotPromise; @@ -1471,6 +1907,71 @@ export abstract class PortfolioCalculator { protected abstract getPerformanceCalculationType(): PerformanceCalculationType; + /** + * Returns the performance percentages of a date of the snapshot chart with + * the invested capital of the performance calculation type + */ + protected getPerformancePercentagesOfAccumulatedValues({ + accumulatedValues: { + totalDividendValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect + }, + investedCapital, + investedCapitalWithCurrencyEffect + }: { + accumulatedValues: AccumulatedValues; + } & InvestedCapital): PerformancePercentages { + return { + dividendInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalDividendValueWithCurrencyEffect + .div(investedCapitalWithCurrencyEffect) + .toNumber(), + netPerformanceInPercentage: investedCapital.eq(0) + ? 0 + : totalNetPerformanceValue.div(investedCapital).toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(investedCapitalWithCurrencyEffect) + .toNumber() + }; + } + + /** + * Returns the performance percentages of an item of a date range with the + * invested capital of the performance calculation type + */ + protected getPerformancePercentagesOfHistoricalDataItem({ + historicalDataItem: { + dividendInBaseCurrency, + netPerformance, + netPerformanceWithCurrencyEffect + }, + investedCapital, + investedCapitalWithCurrencyEffect + }: { + historicalDataItem: HistoricalDataItem; + investedCapital: number; + investedCapitalWithCurrencyEffect: number; + }): PerformancePercentages { + return { + dividendInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect > 0 + ? dividendInBaseCurrency / investedCapitalWithCurrencyEffect + : 0, + netPerformanceInPercentage: + investedCapital > 0 ? netPerformance / investedCapital : 0, + netPerformanceInPercentageWithCurrencyEffect: + investedCapitalWithCurrencyEffect > 0 + ? netPerformanceWithCurrencyEffect / investedCapitalWithCurrencyEffect + : 0 + }; + } + public async getSnapshot() { await this.snapshotPromise; @@ -1818,6 +2319,23 @@ export abstract class PortfolioCalculator { return chartDateMap; } + private getDividendYieldPercent({ + daysInMarket, + dividendInBaseCurrency, + investedCapital + }: { + daysInMarket: number; + dividendInBaseCurrency: Big; + investedCapital: Big; + }) { + return getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: investedCapital.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(investedCapital) + }); + } + @LogPerformance private async initialize(attempt = 1) { const startTimeTotal = performance.now(); 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 7483250e1f..f0af309d75 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -2,19 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { AverageInvestmentItem } from '@ghostfolio/api/app/portfolio/interfaces/average-investment-item.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 { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { - getAnnualizedPerformancePercent, - getIntervalFromDateRange -} from '@ghostfolio/common/calculation-helper'; -import { - DATE_FORMAT, - getAssetProfileIdentifier, - parseDate -} from '@ghostfolio/common/helper'; +import { getSum, parseDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem @@ -24,144 +15,39 @@ import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { - differenceInDays, - eachYearOfInterval, - format, - isBefore, - isThisYear -} from 'date-fns'; -import { sortedIndex } from 'lodash-es'; +import { differenceInDays } from 'date-fns'; +import { mapValues, sortedIndex } from 'lodash-es'; export class RoaiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance( - positions: PortfolioCalculatorHolding[] - ): PortfolioSnapshot { - let currentValueInBaseCurrency = new Big(0); - let hasErrors = false; - let totalAverageInvestment = new Big(0); - let totalAverageInvestmentWithCurrencyEffect = new Big(0); - let totalFeesWithCurrencyEffect = new Big(0); - const totalInterestWithCurrencyEffect = new Big(0); - let totalInvestment = new Big(0); - let totalInvestmentWithCurrencyEffect = new Big(0); - - for (const currentPosition of positions) { - if (currentPosition.valueInBaseCurrency) { - currentValueInBaseCurrency = currentValueInBaseCurrency.plus( - currentPosition.valueInBaseCurrency - ); - } else { - hasErrors = true; - } - - if (!currentPosition.includeInPerformance) { - continue; - } - - if (currentPosition.feeInBaseCurrency) { - totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( - currentPosition.feeInBaseCurrency - ); - } - - if (currentPosition.investment) { - totalInvestment = totalInvestment.plus(currentPosition.investment); - - totalInvestmentWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.plus( - currentPosition.investmentWithCurrencyEffect - ); - } else { - hasErrors = true; - } - - if ( - !currentPosition.grossPerformance && - !currentPosition.quantity.eq(0) - ) { - hasErrors = true; - } - - if (currentPosition.averageInvestment) { - totalAverageInvestment = totalAverageInvestment.plus( - currentPosition.averageInvestment - ); - - totalAverageInvestmentWithCurrencyEffect = - totalAverageInvestmentWithCurrencyEffect.plus( - currentPosition.averageInvestmentWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - this.logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})` - ); - - hasErrors = true; - } - } - - const dateOfFirstActivity = this.getStartDate(); - - const daysInMarket = dateOfFirstActivity - ? differenceInDays(new Date(), dateOfFirstActivity) - : 0; - - 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)) - ); + protected calculateOverallPerformance({ + positions + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + positions: PortfolioCalculatorHolding[]; + }): PortfolioSnapshot { + // The invested capital of the portfolio is the sum of the average + // investments of the holdings + const positionsIncludedInPerformance = positions.filter( + ({ includeInPerformance }) => { + return includeInPerformance; } ); - 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, + return this.getOverallPerformance({ positions, - totalFeesWithCurrencyEffect, - totalInterestWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - activitiesCount: this.activities.filter(({ type }) => { - return ['BUY', 'SELL'].includes(type); - }).length, - createdAt: new Date(), - errors: [], - historicalData: [], - totalCashInBaseCurrency: new Big(0), - totalLiabilitiesWithCurrencyEffect: new Big(0) - }; + investedCapital: getSum( + positionsIncludedInPerformance.map(({ averageInvestment }) => { + return averageInvestment; + }) + ), + investedCapitalWithCurrencyEffect: getSum( + positionsIncludedInPerformance.map( + ({ averageInvestmentWithCurrencyEffect }) => { + return averageInvestmentWithCurrencyEffect; + } + ) + ) + }); } protected calculatePerformancePercentages({ @@ -169,42 +55,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; }): { [date: string]: PerformancePercentages } { - const performancePercentagesByDate: { - [date: string]: PerformancePercentages; - } = {}; - - for (const [ - date, - { - totalAverageInvestmentValue, - totalAverageInvestmentValueWithCurrencyEffect, - totalDividendValueWithCurrencyEffect, - totalNetPerformanceValue, - totalNetPerformanceValueWithCurrencyEffect - } - ] of Object.entries(accumulatedValuesByDate)) { - performancePercentagesByDate[date] = { - dividendInPercentageWithCurrencyEffect: - totalAverageInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalDividendValueWithCurrencyEffect - .div(totalAverageInvestmentValueWithCurrencyEffect) - .toNumber(), - netPerformanceInPercentage: totalAverageInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(totalAverageInvestmentValue) - .toNumber(), - netPerformanceInPercentageWithCurrencyEffect: - totalAverageInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalAverageInvestmentValueWithCurrencyEffect) - .toNumber() - }; - } - - return performancePercentagesByDate; + return mapValues(accumulatedValuesByDate, (accumulatedValues) => { + return this.getPerformancePercentagesOfAccumulatedValues({ + accumulatedValues, + investedCapital: accumulatedValues.totalAverageInvestmentValue, + investedCapitalWithCurrencyEffect: + accumulatedValues.totalAverageInvestmentValueWithCurrencyEffect + }); + }); } protected calculatePerformancePercentagesForDateRange({ @@ -258,31 +116,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }); for (const [index, historicalDataItem] of historicalDataItems.entries()) { - const averageInvestmentValue = this.getAverageInvestment( - weightedInvestments[index] - ).toNumber(); - - const averageInvestmentValueWithCurrencyEffect = - this.getAverageInvestment( - weightedInvestmentsWithCurrencyEffect[index] - ).toNumber(); - - performancePercentagesByDate[historicalDataItem.date] = { - dividendInPercentageWithCurrencyEffect: - averageInvestmentValueWithCurrencyEffect > 0 - ? historicalDataItem.dividendInBaseCurrency / - averageInvestmentValueWithCurrencyEffect - : 0, - netPerformanceInPercentage: - averageInvestmentValue > 0 - ? historicalDataItem.netPerformance / averageInvestmentValue - : 0, - netPerformanceInPercentageWithCurrencyEffect: - averageInvestmentValueWithCurrencyEffect > 0 - ? historicalDataItem.netPerformanceWithCurrencyEffect / - averageInvestmentValueWithCurrencyEffect - : 0 - }; + performancePercentagesByDate[historicalDataItem.date] = + this.getPerformancePercentagesOfHistoricalDataItem({ + historicalDataItem, + investedCapital: this.getAverageInvestment( + weightedInvestments[index] + ).toNumber(), + investedCapitalWithCurrencyEffect: this.getAverageInvestment( + weightedInvestmentsWithCurrencyEffect[index] + ).toNumber() + }); } return performancePercentagesByDate; @@ -307,6 +150,31 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { + const { holdingPerformance, valuation } = + this.getMethodIndependentHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }); + + if (!valuation) { + return holdingPerformance; + } + + const { + currentValuesWithCurrencyEffect, + dateRanges, + daysInMarket, + indexOfEndActivity, + indexOfStartActivity, + investmentValuesAccumulatedWithCurrencyEffect, + items + } = valuation; + const averageInvestmentValues: { [date: string]: Big } = {}; const averageInvestmentValuesWithCurrencyEffect: { @@ -318,107 +186,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let valueAtStartDate: Big; let valueAtStartDateWithCurrencyEffect: Big; - const assetProfileIdentifier = getAssetProfileIdentifier({ - dataSource, - symbol - }); - - let activities: PortfolioCalculatorActivityItem[] = - this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; - - const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; - - if (activities.length <= 0) { - return this.getEmptyHoldingPerformance(); - } - - // The dividends, the interest and the liabilities are derived from the - // activities only. Accumulate them upfront so that they survive the bail - // out for symbols without a market price below. - const { - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalLiabilitiesInBaseCurrency - } = this.getTotalsFromActivities({ activities, exchangeRates }); - - const dateOfFirstActivity = parseDate(activities[0].date); - - const endDateString = format(end, DATE_FORMAT); - const startDateString = format(start, DATE_FORMAT); - - const unitPriceAtStartDate = - marketSymbolMap[startDateString]?.[assetProfileIdentifier]; - - const unitPriceAtEndDate = this.getUnitPriceAtEndDate({ - activities, - dataSource, - isCash, - marketPriceAtEndDate: - marketSymbolMap[endDateString]?.[assetProfileIdentifier] - }); - - if ( - !unitPriceAtEndDate || - (!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start)) - ) { - // A missing market price can only affect the quantity which is held. The - // dividends, the interest and the liabilities do not hold any quantity - // and are therefore not in error. - const hasActivitiesWithQuantity = activities.some(({ type }) => { - return ['BUY', 'SELL'].includes(type); - }); - - return { - ...this.getEmptyHoldingPerformance(), - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalLiabilitiesInBaseCurrency, - hasErrors: hasActivitiesWithQuantity - }; - } - - activities = this.getActivitiesWithMarketPrices({ - activities, - chartDates, - endDateString, - marketSymbolMap, - startDateString, - unitPriceAtEndDate, - unitPriceAtStartDate, - assetProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - } - }); - - const { - currentValues, - currentValuesWithCurrencyEffect, - dividendValuesWithCurrencyEffect, - initialValue, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - items, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect - } = this.getHoldingValuation({ - activities, - exchangeRates, - unitPriceAtStartDate - }); - - const indexOfStartActivity = items.findIndex(({ itemType }) => { - return itemType === 'start'; - }); - - const indexOfEndActivity = items.findIndex(({ itemType }) => { - return itemType === 'end'; - }); - let sumOfWeightedInvestments = new Big(0); let sumOfWeightedInvestmentsWithCurrencyEffect = new Big(0); let totalInvestmentDays = 0; @@ -503,37 +270,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } - const { - fees: feesAtStartDate, - feesWithCurrencyEffect: feesAtStartDateWithCurrencyEffect, - grossPerformance: grossPerformanceAtStartDate, - grossPerformanceWithCurrencyEffect: - grossPerformanceAtStartDateWithCurrencyEffect - } = items[indexOfStartActivity]; - - const { - fees, - feesWithCurrencyEffect, - grossPerformance, - grossPerformanceWithCurrencyEffect, - investment: totalInvestment, - investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - quantity: totalQuantity - } = items[indexOfEndActivity]; - - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalGrossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.minus( - grossPerformanceAtStartDateWithCurrencyEffect - ); - - const totalNetPerformance = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - const averageInvestmentBetweenStartAndEndDate = totalInvestmentDays > 0 ? sumOfWeightedInvestments.div(totalInvestmentDays) @@ -544,91 +280,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ? sumOfWeightedInvestmentsWithCurrencyEffect.div(totalInvestmentDays) : new Big(0); - const grossPerformancePercentage = - averageInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div(averageInvestmentBetweenStartAndEndDate) - : new Big(0); - - const grossPerformancePercentageWithCurrencyEffect = - averageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(0) - ? totalGrossPerformanceWithCurrencyEffect.div( - averageInvestmentBetweenStartAndEndDateWithCurrencyEffect - ) - : new Big(0); - - const feesPerUnit = totalQuantity.gt(0) - ? fees.minus(feesAtStartDate).div(totalQuantity) - : new Big(0); - - const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0) - ? feesWithCurrencyEffect - .minus(feesAtStartDateWithCurrencyEffect) - .div(totalQuantity) - : new Big(0); - - const netPerformancePercentage = averageInvestmentBetweenStartAndEndDate.gt( - 0 - ) - ? 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; - } = {}; - - const netPerformanceWithCurrencyEffectMap: { - [key: DateRange]: Big; - } = {}; - - for (const dateRange of [ - '1d', - '1y', - '5y', - 'max', - 'mtd', - 'wtd', - 'ytd', - ...eachYearOfInterval({ end, start }) - .filter((date) => { - return !isThisYear(date); - }) - .map((date) => { - return format(date, 'yyyy'); - }) - ] as DateRange[]) { - const dateInterval = getIntervalFromDateRange({ dateRange }); - const endDate = dateInterval.endDate; - let startDate = dateInterval.startDate; - - if (isBefore(startDate, start)) { - startDate = start; - } - - const rangeEndDateString = format(endDate, DATE_FORMAT); - const rangeStartDateString = format(startDate, DATE_FORMAT); + const averageInvestmentByDateRange: { [key: DateRange]: Big } = {}; + for (const { + dateRange, + endDateString: rangeEndDateString, + startDateString: rangeStartDateString + } of dateRanges) { const currentValuesAtDateRangeStartWithCurrencyEffect = currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); @@ -667,27 +325,61 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { items: averageInvestmentItems }).at(-1); - const average = weightedInvestment + averageInvestmentByDateRange[dateRange] = weightedInvestment ? this.getAverageInvestment(weightedInvestment) : new Big(0); - - netPerformanceWithCurrencyEffectMap[dateRange] = - netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( - // If the date range is 'max', take 0 as a start value. Otherwise, - // the value of the end of the day of the start date is taken which - // differs from the buying price. - dateRange === 'max' - ? new Big(0) - : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? - new Big(0)) - ) ?? new Big(0); - - netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) - ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) - : new Big(0); } + const holdingPerformanceWithAverageInvestment = + this.getHoldingPerformanceWithInvestedCapital({ + daysInMarket, + holdingPerformance, + investedCapital: averageInvestmentBetweenStartAndEndDate, + investedCapitalByDateRange: averageInvestmentByDateRange, + investedCapitalValues: averageInvestmentValues, + investedCapitalValuesWithCurrencyEffect: + averageInvestmentValuesWithCurrencyEffect, + investedCapitalWithCurrencyEffect: + averageInvestmentBetweenStartAndEndDateWithCurrencyEffect + }); + if (PortfolioCalculator.ENABLE_LOGGING) { + const { activities, unitPriceAtEndDate } = valuation; + + const { + fees: feesAtStartDate, + feesWithCurrencyEffect: feesAtStartDateWithCurrencyEffect + } = items[indexOfStartActivity]; + + const { + fees, + feesWithCurrencyEffect, + quantity: totalQuantity + } = items[indexOfEndActivity]; + + const { + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + totalDividend, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = holdingPerformanceWithAverageInvestment; + + const feesPerUnit = totalQuantity.gt(0) + ? fees.minus(feesAtStartDate).div(totalQuantity) + : new Big(0); + + const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0) + ? feesWithCurrencyEffect + .minus(feesAtStartDateWithCurrencyEffect) + .div(totalQuantity) + : new Big(0); + console.log( ` ${symbol} @@ -705,10 +397,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { 2 )} Total dividend: ${totalDividend.toFixed(2)} - Gross performance: ${totalGrossPerformance.toFixed( + Gross performance: ${grossPerformance.toFixed( 2 )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% - Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( + Gross performance with currency effect: ${grossPerformanceWithCurrencyEffect.toFixed( 2 )} / ${grossPerformancePercentageWithCurrencyEffect .mul(100) @@ -717,7 +409,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( 2 )} - Net performance: ${totalNetPerformance.toFixed( + Net performance: ${netPerformance.toFixed( 2 )} / ${netPerformancePercentage.mul(100).toFixed(2)}% Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ @@ -726,39 +418,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ); } - return { - averageInvestmentValues, - averageInvestmentValuesWithCurrencyEffect, - currentValues, - currentValuesWithCurrencyEffect, - dividendValuesWithCurrencyEffect, - dividendYieldPercent, - dividendYieldPercentWithCurrencyEffect, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffectMap, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - netPerformanceWithCurrencyEffectMap, - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalInvestment, - totalInvestmentWithCurrencyEffect, - totalLiabilitiesInBaseCurrency, - averageInvestment: averageInvestmentBetweenStartAndEndDate, - averageInvestmentWithCurrencyEffect: - averageInvestmentBetweenStartAndEndDateWithCurrencyEffect, - grossPerformance: totalGrossPerformance, - grossPerformanceWithCurrencyEffect: - totalGrossPerformanceWithCurrencyEffect, - hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance - }; + return holdingPerformanceWithAverageInvestment; } protected getPerformanceCalculationType() { diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts index 57e83b486d..6a66322fcb 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts @@ -83,61 +83,61 @@ describe('PortfolioCalculator (ROI)', () => { }); describe('get current positions', () => { - it.only('with BALN.SW sell and buy on the same day', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - - // The second holding is BALN.SW of another data source, so that both - // holdings have the same market prices - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 + // The second holding is BALN.SW of another data source, so that both + // holdings have the same market prices + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'EOD_HISTORICAL_DATA', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'EOD_HISTORICAL_DATA', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + it.only('with BALN.SW sell and buy on the same day', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, @@ -233,5 +233,58 @@ describe('PortfolioCalculator (ROI)', () => { } }); }); + + it.only('with BALN.SW sell and buy on the same day and a dividend', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [ + ...activities, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'EOD_HISTORICAL_DATA', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-12-17'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.43 + } + ], + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + usePortfolioSnapshotCache: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + /** + * The dividend yield of the holding uses the invested capital of the + * holding: (1 + 2.86 CHF / 273.2 CHF) ^ (365 / 18 days) - 1 ≈ 23.51 % + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + averageInvestment: new Big('273.2'), + dataSource: 'EOD_HISTORICAL_DATA', + dividendInBaseCurrency: new Big('2.86'), + dividendYieldPercent: new Big('0.2351283862005056') + }); + + /** + * The dividend yield of the portfolio uses the invested capital of the + * portfolio, 285.8 CHF, and not the sum of the holdings, 559 CHF: + * (1 + 2.86 CHF / 285.8 CHF) ^ (365 / 26 days) - 1 ≈ 15.00 % + */ + expect(portfolioSnapshot).toMatchObject({ + dividendYieldPercent: new Big('0.1500260608512471'), + dividendYieldPercentWithCurrencyEffect: new Big('0.1500260608512471') + }); + }); }); }); 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 f5a33d371e..4090995f80 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,18 +1,9 @@ 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 { InvestedCapital } from '@ghostfolio/api/app/portfolio/interfaces/invested-capital.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { - getAnnualizedPerformancePercent, - getIntervalFromDateRange -} from '@ghostfolio/common/calculation-helper'; -import { - DATE_FORMAT, - getAssetProfileIdentifier, - parseDate -} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem @@ -22,13 +13,7 @@ import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { - differenceInDays, - eachYearOfInterval, - format, - isBefore, - isThisYear -} from 'date-fns'; +import { mapValues, sortedLastIndex } from 'lodash-es'; /** * Return on Investment (ROI): the gain divided by the invested capital, without @@ -51,134 +36,27 @@ import { * another on different dates counts two times. */ export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance( - positions: PortfolioCalculatorHolding[] - ): PortfolioSnapshot { - let currentValueInBaseCurrency = new Big(0); - let hasErrors = false; - let totalFeesWithCurrencyEffect = new Big(0); - const totalInterestWithCurrencyEffect = new Big(0); - let totalInvestedCapital = new Big(0); - let totalInvestedCapitalWithCurrencyEffect = new Big(0); - let totalInvestment = new Big(0); - let totalInvestmentWithCurrencyEffect = new Big(0); - - for (const currentPosition of positions) { - if (currentPosition.valueInBaseCurrency) { - currentValueInBaseCurrency = currentValueInBaseCurrency.plus( - currentPosition.valueInBaseCurrency - ); - } else { - hasErrors = true; - } - - if (!currentPosition.includeInPerformance) { - continue; - } - - if (currentPosition.feeInBaseCurrency) { - totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( - currentPosition.feeInBaseCurrency - ); - } - - if (currentPosition.investment) { - totalInvestment = totalInvestment.plus(currentPosition.investment); - - totalInvestmentWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.plus( - currentPosition.investmentWithCurrencyEffect - ); - } else { - hasErrors = true; - } - - if ( - !currentPosition.grossPerformance && - !currentPosition.quantity.eq(0) - ) { - hasErrors = true; - } - - if (currentPosition.averageInvestment) { - totalInvestedCapital = totalInvestedCapital.plus( - currentPosition.averageInvestment - ); - - totalInvestedCapitalWithCurrencyEffect = - totalInvestedCapitalWithCurrencyEffect.plus( - currentPosition.averageInvestmentWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - this.logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})` - ); - - hasErrors = true; - } - } - - const dateOfFirstActivity = this.getStartDate(); - - const daysInMarket = dateOfFirstActivity - ? differenceInDays(new Date(), dateOfFirstActivity) - : 0; - - 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 invested capital. Such a - // holding makes the dividend yield too high. Therefore the dividend yield - // stays 0 in this case. - const hasDividendWithoutInvestedCapital = positions.some( - ({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => { - return ( - !dividendInBaseCurrency.eq(0) && - (!includeInPerformance || averageInvestment.eq(0)) - ); - } - ); + protected calculateOverallPerformance({ + accumulatedValuesByDate, + positions + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + positions: PortfolioCalculatorHolding[]; + }): PortfolioSnapshot { + // The invested capital of the portfolio at the last date + const { investedCapital, investedCapitalWithCurrencyEffect } = + Object.values( + this.getInvestedCapitalByDate({ accumulatedValuesByDate }) + ).at(-1) ?? { + investedCapital: new Big(0), + investedCapitalWithCurrencyEffect: new Big(0) + }; - const dividendYieldPercent = getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercentage: - hasDividendWithoutInvestedCapital || totalInvestedCapital.eq(0) - ? new Big(0) - : totalDividendInBaseCurrency.div(totalInvestedCapital) + return this.getOverallPerformance({ + investedCapital, + investedCapitalWithCurrencyEffect, + positions }); - - const dividendYieldPercentWithCurrencyEffect = - getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercentage: - hasDividendWithoutInvestedCapital || - totalInvestedCapitalWithCurrencyEffect.eq(0) - ? new Big(0) - : totalDividendInBaseCurrency.div( - totalInvestedCapitalWithCurrencyEffect - ) - }); - - return { - currentValueInBaseCurrency, - dividendYieldPercent, - dividendYieldPercentWithCurrencyEffect, - hasErrors, - positions, - totalFeesWithCurrencyEffect, - totalInterestWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - activitiesCount: this.activities.filter(({ type }) => { - return ['BUY', 'SELL'].includes(type); - }).length, - createdAt: new Date(), - errors: [], - historicalData: [], - totalCashInBaseCurrency: new Big(0), - totalLiabilitiesWithCurrencyEffect: new Big(0) - }; } protected calculatePerformancePercentages({ @@ -186,74 +64,16 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }: { accumulatedValuesByDate: { [date: string]: AccumulatedValues }; }): { [date: string]: PerformancePercentages } { - let investedCapital = new Big(0); - let investedCapitalWithCurrencyEffect = new Big(0); - let previousTotalInvestmentValue = new Big(0); - let previousTotalInvestmentValueWithCurrencyEffect = new Big(0); - - const performancePercentagesByDate: { - [date: string]: PerformancePercentages; - } = {}; - - // The dates are in chronological order - for (const [ - date, - { - totalDividendValueWithCurrencyEffect, - totalInvestmentValue, - totalInvestmentValueWithCurrencyEffect, - totalNetPerformanceValue, - totalNetPerformanceValueWithCurrencyEffect - } - ] of Object.entries(accumulatedValuesByDate)) { - // A positive net investment change of the portfolio increases the - // invested capital. A negative change does not reduce it, so that a sold - // holding keeps its share of the gain. - const investmentChange = totalInvestmentValue.minus( - previousTotalInvestmentValue - ); - - if (investmentChange.gt(0)) { - investedCapital = investedCapital.plus(investmentChange); - } - - const investmentChangeWithCurrencyEffect = - totalInvestmentValueWithCurrencyEffect.minus( - previousTotalInvestmentValueWithCurrencyEffect - ); - - if (investmentChangeWithCurrencyEffect.gt(0)) { - investedCapitalWithCurrencyEffect = - investedCapitalWithCurrencyEffect.plus( - investmentChangeWithCurrencyEffect - ); - } - - previousTotalInvestmentValue = totalInvestmentValue; - - previousTotalInvestmentValueWithCurrencyEffect = - totalInvestmentValueWithCurrencyEffect; - - performancePercentagesByDate[date] = { - dividendInPercentageWithCurrencyEffect: - investedCapitalWithCurrencyEffect.eq(0) - ? 0 - : totalDividendValueWithCurrencyEffect - .div(investedCapitalWithCurrencyEffect) - .toNumber(), - netPerformanceInPercentage: investedCapital.eq(0) - ? 0 - : totalNetPerformanceValue.div(investedCapital).toNumber(), - netPerformanceInPercentageWithCurrencyEffect: - investedCapitalWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(investedCapitalWithCurrencyEffect) - .toNumber() - }; - } + const investedCapitalByDate = this.getInvestedCapitalByDate({ + accumulatedValuesByDate + }); - return performancePercentagesByDate; + return mapValues(accumulatedValuesByDate, (accumulatedValues, date) => { + return this.getPerformancePercentagesOfAccumulatedValues({ + accumulatedValues, + ...investedCapitalByDate[date] + }); + }); } protected calculatePerformancePercentagesForDateRange({ @@ -297,22 +117,12 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { } } - performancePercentagesByDate[historicalDataItem.date] = { - dividendInPercentageWithCurrencyEffect: - investedCapitalWithCurrencyEffect > 0 - ? historicalDataItem.dividendInBaseCurrency / - investedCapitalWithCurrencyEffect - : 0, - netPerformanceInPercentage: - investedCapital > 0 - ? historicalDataItem.netPerformance / investedCapital - : 0, - netPerformanceInPercentageWithCurrencyEffect: - investedCapitalWithCurrencyEffect > 0 - ? historicalDataItem.netPerformanceWithCurrencyEffect / - investedCapitalWithCurrencyEffect - : 0 - }; + performancePercentagesByDate[historicalDataItem.date] = + this.getPerformancePercentagesOfHistoricalDataItem({ + historicalDataItem, + investedCapital, + investedCapitalWithCurrencyEffect + }); } return performancePercentagesByDate; @@ -335,106 +145,29 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { - const assetProfileIdentifier = getAssetProfileIdentifier({ - dataSource, - symbol - }); - - let activities: PortfolioCalculatorActivityItem[] = - this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; - - const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; - - if (activities.length <= 0) { - return this.getEmptyHoldingPerformance(); - } - - // The dividends, the interest and the liabilities are derived from the - // activities only. Accumulate them upfront so that they survive the bail - // out for symbols without a market price below. - const { - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalLiabilitiesInBaseCurrency - } = this.getTotalsFromActivities({ activities, exchangeRates }); - - const dateOfFirstActivity = parseDate(activities[0].date); - - const endDateString = format(end, DATE_FORMAT); - const startDateString = format(start, DATE_FORMAT); - - const unitPriceAtStartDate = - marketSymbolMap[startDateString]?.[assetProfileIdentifier]; - - const unitPriceAtEndDate = this.getUnitPriceAtEndDate({ - activities, - dataSource, - isCash, - marketPriceAtEndDate: - marketSymbolMap[endDateString]?.[assetProfileIdentifier] - }); - - if ( - !unitPriceAtEndDate || - (!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start)) - ) { - // A missing market price can only affect the quantity which is held. The - // dividends, the interest and the liabilities do not hold any quantity - // and are therefore not in error. - const hasActivitiesWithQuantity = activities.some(({ type }) => { - return ['BUY', 'SELL'].includes(type); + const { holdingPerformance, valuation } = + this.getMethodIndependentHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol }); - return { - ...this.getEmptyHoldingPerformance(), - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalLiabilitiesInBaseCurrency, - hasErrors: hasActivitiesWithQuantity - }; + if (!valuation) { + return holdingPerformance; } - activities = this.getActivitiesWithMarketPrices({ - activities, - chartDates, - endDateString, - marketSymbolMap, - startDateString, - unitPriceAtEndDate, - unitPriceAtStartDate, - assetProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - } - }); - const { - currentValues, currentValuesWithCurrencyEffect, - dividendValuesWithCurrencyEffect, - initialValue, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - items, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect - } = this.getHoldingValuation({ - activities, - exchangeRates, - unitPriceAtStartDate - }); - - const indexOfStartActivity = items.findIndex(({ itemType }) => { - return itemType === 'start'; - }); - - const indexOfEndActivity = items.findIndex(({ itemType }) => { - return itemType === 'end'; - }); + dateRanges, + daysInMarket, + indexOfEndActivity, + indexOfStartActivity, + items + } = valuation; let investedCapital = new Big(0); let investedCapitalWithCurrencyEffect = new Big(0); @@ -455,174 +188,117 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { } } - const { - fees: feesAtStartDate, - grossPerformance: grossPerformanceAtStartDate, - grossPerformanceWithCurrencyEffect: - grossPerformanceAtStartDateWithCurrencyEffect - } = items[indexOfStartActivity]; + // The sum of the buy amounts up to each item. The items are in + // chronological order. + const buyAmountsAccumulatedWithCurrencyEffect: Big[] = []; + let buyAmountWithCurrencyEffect = new Big(0); - const { - fees, - grossPerformance, - grossPerformanceWithCurrencyEffect, - investment: totalInvestment, - investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - quantity: totalQuantity - } = items[indexOfEndActivity]; - - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalGrossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.minus( - grossPerformanceAtStartDateWithCurrencyEffect - ); + for (const { transactionInvestmentWithCurrencyEffect } of items) { + if (transactionInvestmentWithCurrencyEffect.gt(0)) { + buyAmountWithCurrencyEffect = buyAmountWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } - const totalNetPerformance = totalGrossPerformance.minus( - fees.minus(feesAtStartDate) - ); + buyAmountsAccumulatedWithCurrencyEffect.push(buyAmountWithCurrencyEffect); + } - const grossPerformancePercentage = investedCapital.gt(0) - ? totalGrossPerformance.div(investedCapital) - : new Big(0); + const itemDates = items.map(({ date }) => { + return date; + }); - const grossPerformancePercentageWithCurrencyEffect = - investedCapitalWithCurrencyEffect.gt(0) - ? totalGrossPerformanceWithCurrencyEffect.div( - investedCapitalWithCurrencyEffect - ) - : new Big(0); + const investedCapitalByDateRange: { [key: DateRange]: Big } = {}; - const netPerformancePercentage = investedCapital.gt(0) - ? totalNetPerformance.div(investedCapital) - : new Big(0); + for (const { dateRange, endDateString, startDateString } of dateRanges) { + const buyAmountUntilEndDateWithCurrencyEffect = + buyAmountsAccumulatedWithCurrencyEffect[ + sortedLastIndex(itemDates, endDateString) - 1 + ] ?? new Big(0); - const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + const buyAmountUntilStartDateWithCurrencyEffect = + buyAmountsAccumulatedWithCurrencyEffect[ + sortedLastIndex(itemDates, startDateString) - 1 + ] ?? new Big(0); - const dividendYieldPercent = getAnnualizedPerformancePercent({ + // The invested capital of the date range is the value at the start date + // plus the buy amounts during the range + investedCapitalByDateRange[dateRange] = ( + currentValuesWithCurrencyEffect[startDateString] ?? new Big(0) + ).plus( + buyAmountUntilEndDateWithCurrencyEffect.minus( + buyAmountUntilStartDateWithCurrencyEffect + ) + ); + } + + return this.getHoldingPerformanceWithInvestedCapital({ daysInMarket, - netPerformancePercentage: investedCapital.eq(0) - ? new Big(0) - : totalDividendInBaseCurrency.div(investedCapital) + holdingPerformance, + investedCapital, + investedCapitalByDateRange, + investedCapitalWithCurrencyEffect }); + } - const dividendYieldPercentWithCurrencyEffect = - getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercentage: investedCapitalWithCurrencyEffect.eq(0) - ? new Big(0) - : totalDividendInBaseCurrency.div(investedCapitalWithCurrencyEffect) - }); + protected getPerformanceCalculationType() { + return PerformanceCalculationType.ROI; + } - const netPerformancePercentageWithCurrencyEffectMap: { - [key: DateRange]: Big; - } = {}; + /** + * Returns the invested capital of the portfolio per date: the sum of the + * positive net investment changes up to the date + */ + private getInvestedCapitalByDate({ + accumulatedValuesByDate + }: { + accumulatedValuesByDate: { [date: string]: AccumulatedValues }; + }) { + let investedCapital = new Big(0); + let investedCapitalWithCurrencyEffect = new Big(0); + let previousTotalInvestmentValue = new Big(0); + let previousTotalInvestmentValueWithCurrencyEffect = new Big(0); - const netPerformanceWithCurrencyEffectMap: { - [key: DateRange]: Big; - } = {}; + const investedCapitalByDate: { [date: string]: InvestedCapital } = {}; + + // The dates are in chronological order + for (const [ + date, + { totalInvestmentValue, totalInvestmentValueWithCurrencyEffect } + ] of Object.entries(accumulatedValuesByDate)) { + // A positive net investment change of the portfolio increases the + // invested capital. A negative change does not reduce it, so that a sold + // holding keeps its share of the gain. + const investmentChange = totalInvestmentValue.minus( + previousTotalInvestmentValue + ); - for (const dateRange of [ - '1d', - '1y', - '5y', - 'max', - 'mtd', - 'wtd', - 'ytd', - ...eachYearOfInterval({ end, start }) - .filter((date) => { - return !isThisYear(date); - }) - .map((date) => { - return format(date, 'yyyy'); - }) - ] as DateRange[]) { - const dateInterval = getIntervalFromDateRange({ dateRange }); - const endDate = dateInterval.endDate; - let startDate = dateInterval.startDate; - - if (isBefore(startDate, start)) { - startDate = start; + if (investmentChange.gt(0)) { + investedCapital = investedCapital.plus(investmentChange); } - const rangeEndDateString = format(endDate, DATE_FORMAT); - const rangeStartDateString = format(startDate, DATE_FORMAT); + const investmentChangeWithCurrencyEffect = + totalInvestmentValueWithCurrencyEffect.minus( + previousTotalInvestmentValueWithCurrencyEffect + ); - // The invested capital of the date range is the value at the start date - // plus the buy amounts during the range - let investedCapitalOfDateRangeWithCurrencyEffect = - currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); - - for (const item of items) { - if ( - item.date > rangeStartDateString && - item.date <= rangeEndDateString && - item.transactionInvestmentWithCurrencyEffect.gt(0) - ) { - investedCapitalOfDateRangeWithCurrencyEffect = - investedCapitalOfDateRangeWithCurrencyEffect.plus( - item.transactionInvestmentWithCurrencyEffect - ); - } + if (investmentChangeWithCurrencyEffect.gt(0)) { + investedCapitalWithCurrencyEffect = + investedCapitalWithCurrencyEffect.plus( + investmentChangeWithCurrencyEffect + ); } - netPerformanceWithCurrencyEffectMap[dateRange] = - netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( - // If the date range is 'max', take 0 as a start value. Otherwise, - // the value of the end of the day of the start date is taken which - // differs from the buying price. - dateRange === 'max' - ? new Big(0) - : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? - new Big(0)) - ) ?? new Big(0); - - netPerformancePercentageWithCurrencyEffectMap[dateRange] = - investedCapitalOfDateRangeWithCurrencyEffect.gt(0) - ? netPerformanceWithCurrencyEffectMap[dateRange].div( - investedCapitalOfDateRangeWithCurrencyEffect - ) - : new Big(0); - } + previousTotalInvestmentValue = totalInvestmentValue; - return { - currentValues, - currentValuesWithCurrencyEffect, - dividendValuesWithCurrencyEffect, - dividendYieldPercent, - dividendYieldPercentWithCurrencyEffect, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffectMap, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - netPerformanceWithCurrencyEffectMap, - totalDividend, - totalDividendInBaseCurrency, - totalInterestInBaseCurrency, - totalInvestment, - totalInvestmentWithCurrencyEffect, - totalLiabilitiesInBaseCurrency, - averageInvestment: investedCapital, - averageInvestmentValues: {}, - averageInvestmentValuesWithCurrencyEffect: {}, - averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect, - grossPerformance: totalGrossPerformance, - grossPerformanceWithCurrencyEffect: - totalGrossPerformanceWithCurrencyEffect, - hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance - }; - } + previousTotalInvestmentValueWithCurrencyEffect = + totalInvestmentValueWithCurrencyEffect; - protected getPerformanceCalculationType() { - return PerformanceCalculationType.ROI; + investedCapitalByDate[date] = { + investedCapital, + investedCapitalWithCurrencyEffect + }; + } + + return investedCapitalByDate; } } diff --git a/apps/api/src/app/portfolio/interfaces/date-range-interval.interface.ts b/apps/api/src/app/portfolio/interfaces/date-range-interval.interface.ts new file mode 100644 index 0000000000..385e07e22e --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/date-range-interval.interface.ts @@ -0,0 +1,7 @@ +import { DateRange } from '@ghostfolio/common/types'; + +export interface DateRangeInterval { + dateRange: DateRange; + endDateString: string; + startDateString: string; +} diff --git a/apps/api/src/app/portfolio/interfaces/holding-performance-valuation.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-performance-valuation.interface.ts new file mode 100644 index 0000000000..1c49969156 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/holding-performance-valuation.interface.ts @@ -0,0 +1,14 @@ +import { Big } from 'big.js'; + +import { DateRangeInterval } from './date-range-interval.interface'; +import { HoldingValuation } from './holding-valuation.interface'; +import { PortfolioCalculatorActivityItem } from './portfolio-calculator-activity-item.interface'; + +export interface HoldingPerformanceValuation extends HoldingValuation { + activities: PortfolioCalculatorActivityItem[]; + dateRanges: DateRangeInterval[]; + daysInMarket: number; + indexOfEndActivity: number; + indexOfStartActivity: number; + unitPriceAtEndDate: Big; +} diff --git a/apps/api/src/app/portfolio/interfaces/invested-capital.interface.ts b/apps/api/src/app/portfolio/interfaces/invested-capital.interface.ts new file mode 100644 index 0000000000..9f1dde4989 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/invested-capital.interface.ts @@ -0,0 +1,6 @@ +import { Big } from 'big.js'; + +export interface InvestedCapital { + investedCapital: Big; + investedCapitalWithCurrencyEffect: Big; +} diff --git a/apps/api/src/app/portfolio/interfaces/method-independent-holding-performance.interface.ts b/apps/api/src/app/portfolio/interfaces/method-independent-holding-performance.interface.ts new file mode 100644 index 0000000000..40c47dbd2d --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/method-independent-holding-performance.interface.ts @@ -0,0 +1,7 @@ +import { HoldingPerformanceValuation } from './holding-performance-valuation.interface'; +import { HoldingPerformance } from './holding-performance.interface'; + +export interface MethodIndependentHoldingPerformance { + holdingPerformance: HoldingPerformance; + valuation?: HoldingPerformanceValuation; +}