diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d172652a83..2a34aaa644 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -10,6 +10,7 @@ import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/i import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { HoldingValuationOfPeriod } from '@ghostfolio/api/app/portfolio/types/holding-valuation-of-period.type'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; @@ -47,7 +48,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,6 +67,7 @@ import { isBefore, isFuture, isPast, + isThisYear, isWithinInterval, min, startOfDay, @@ -891,6 +893,29 @@ export abstract class PortfolioCalculator { return this.dataProviderInfos; } + /** + * Returns the date ranges of the performance of a holding: the fixed ranges + * and each past calendar year between the start and the end date + */ + protected getDateRanges({ end, start }: { end: Date; start: Date }) { + return [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end, start }) + .filter((date) => { + return !isThisYear(date); + }) + .map((date) => { + return format(date, 'yyyy'); + }) + ] as DateRange[]; + } + protected getDividendInBaseCurrencyOfHoldings( holdings: PortfolioSnapshotHolding[] ) { @@ -1354,6 +1379,129 @@ export abstract class PortfolioCalculator { }; } + /** + * Returns the valuation of a holding between the start and the end date. If + * the holding has no activities or a market price is missing, it returns + * the holding performance which only contains the totals of the activities. + */ + protected getHoldingValuationOfPeriod({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDates: string[]; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [assetProfileIdentifier: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): HoldingValuationOfPeriod { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + let activities: PortfolioCalculatorActivityItem[] = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; + + const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; + + if (activities.length <= 0) { + return { + hasMarketPrices: false, + 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 { + hasMarketPrices: false, + 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 + }); + + return { + activities, + dateOfFirstActivity, + holdingValuation, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency, + unitPriceAtEndDate, + hasMarketPrices: true + }; + } + public async getInterestInBaseCurrency() { await this.snapshotPromise; @@ -1561,6 +1709,64 @@ export abstract class PortfolioCalculator { }; } + /** + * Returns the totals of the positions which do not depend on the + * performance calculation type + */ + protected getTotalsOfPositions(positions: PortfolioCalculatorHolding[]) { + 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 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; + } + } + + return { + currentValueInBaseCurrency, + hasErrors, + totalFeesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect + }; + } + protected getUnitPriceAtEndDate({ activities, dataSource, 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..03e5743d39 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -2,7 +2,6 @@ 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'; @@ -10,11 +9,7 @@ import { getAnnualizedPerformancePercent, getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - DATE_FORMAT, - getAssetProfileIdentifier, - parseDate -} from '@ghostfolio/common/helper'; +import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem @@ -24,65 +19,32 @@ 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 { differenceInDays, format, isBefore } from 'date-fns'; import { sortedIndex } from 'lodash-es'; export class RoaiPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance( positions: PortfolioCalculatorHolding[] ): PortfolioSnapshot { - let currentValueInBaseCurrency = new Big(0); - let hasErrors = false; + const totalsOfPositions = this.getTotalsOfPositions(positions); + + const { + currentValueInBaseCurrency, + totalFeesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = totalsOfPositions; + + let { hasErrors } = totalsOfPositions; 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 @@ -318,98 +280,41 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let valueAtStartDate: Big; let valueAtStartDateWithCurrencyEffect: Big; - const assetProfileIdentifier = getAssetProfileIdentifier({ + const holdingValuationOfPeriod = this.getHoldingValuationOfPeriod({ + chartDates, dataSource, + end, + exchangeRates, + marketSymbolMap, + start, symbol }); - let activities: PortfolioCalculatorActivityItem[] = - this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; - - const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; - - if (activities.length <= 0) { - return this.getEmptyHoldingPerformance(); + if (holdingValuationOfPeriod.hasMarketPrices === false) { + return holdingValuationOfPeriod.holdingPerformance; } - // 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 { + activities, + dateOfFirstActivity, + holdingValuation: { + currentValues, + currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, + initialValue, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + items, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect + }, 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 - }); + totalLiabilitiesInBaseCurrency, + unitPriceAtEndDate + } = holdingValuationOfPeriod; const indexOfStartActivity = items.findIndex(({ itemType }) => { return itemType === 'start'; @@ -602,22 +507,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { [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[]) { + for (const dateRange of this.getDateRanges({ end, start })) { const dateInterval = getIntervalFromDateRange({ dateRange }); const endDate = dateInterval.endDate; let startDate = dateInterval.startDate; diff --git a/apps/api/src/app/portfolio/types/holding-valuation-of-period.type.ts b/apps/api/src/app/portfolio/types/holding-valuation-of-period.type.ts new file mode 100644 index 0000000000..61d71bf0b1 --- /dev/null +++ b/apps/api/src/app/portfolio/types/holding-valuation-of-period.type.ts @@ -0,0 +1,22 @@ +import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; +import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface'; +import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; + +import { Big } from 'big.js'; + +export type HoldingValuationOfPeriod = + | { + hasMarketPrices: false; + holdingPerformance: HoldingPerformance; + } + | { + activities: PortfolioCalculatorActivityItem[]; + dateOfFirstActivity: Date; + hasMarketPrices: true; + holdingValuation: HoldingValuation; + totalDividend: Big; + totalDividendInBaseCurrency: Big; + totalInterestInBaseCurrency: Big; + totalLiabilitiesInBaseCurrency: Big; + unitPriceAtEndDate: Big; + };