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 98e3fc9a4b..2d249cc487 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -11,7 +11,7 @@ export class MwrPortfolioCalculator extends PortfolioCalculator { } protected getHoldingPerformance({}: { - chartDateMap: { [date: string]: boolean }; + chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 4cb88296cd..fb586e8248 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -2,6 +2,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; +import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; @@ -43,10 +44,11 @@ import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Logger } from '@nestjs/common'; -import { AssetSubClass } from '@prisma/client'; +import { AssetSubClass, DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { + addMilliseconds, differenceInDays, eachDayOfInterval, eachYearOfInterval, @@ -425,7 +427,7 @@ export abstract class PortfolioCalculator { totalInvestmentWithCurrencyEffect, totalLiabilitiesInBaseCurrency } = this.getHoldingPerformance({ - chartDateMap, + chartDates, marketSymbolMap, dataSource: item.dataSource, end: this.endDate, @@ -728,6 +730,37 @@ export abstract class PortfolioCalculator { ); } + protected getEmptyHoldingPerformance(): HoldingPerformance { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffectMap: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0) + }; + } + public async getFeesInBaseCurrency() { await this.snapshotPromise; @@ -735,7 +768,7 @@ export abstract class PortfolioCalculator { } protected abstract getHoldingPerformance({ - chartDateMap, + chartDates, dataSource, end, exchangeRates, @@ -743,7 +776,7 @@ export abstract class PortfolioCalculator { start, symbol }: { - chartDateMap: { [date: string]: boolean }; + chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { @@ -804,6 +837,137 @@ export abstract class PortfolioCalculator { return this.snapshot.totalLiabilitiesWithCurrencyEffect; } + protected getOrdersWithMarketPrices({ + assetProfile, + chartDates, + endDateString, + marketSymbolMap, + orders, + startDateString, + unitPriceAtEndDate, + unitPriceAtStartDate + }: { + assetProfile: PortfolioOrderItem['assetProfile']; + chartDates: string[]; + endDateString: string; + marketSymbolMap: { + [date: string]: { [assetProfileIdentifier: string]: Big }; + }; + orders: PortfolioOrderItem[]; + startDateString: string; + unitPriceAtEndDate: Big; + unitPriceAtStartDate: Big; + }): PortfolioOrderItem[] { + if (orders.length <= 0) { + return []; + } + + const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile); + const dateStringOfFirstActivity = orders[0].date; + + // Copy the items as they are enriched below. A shallow copy is sufficient + // because only top-level properties are written. + const ordersWithMarketPrices = orders.map((order) => { + return { ...order }; + }); + + // Add a synthetic order at the start and the end date + ordersWithMarketPrices.push({ + assetProfile, + date: startDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + type: 'BUY', + unitPrice: unitPriceAtStartDate + }); + + ordersWithMarketPrices.push({ + assetProfile, + date: endDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + quantity: new Big(0), + type: 'BUY', + unitPrice: unitPriceAtEndDate + }); + + // Fall back to the unit price of the most recent BUY / SELL activity for + // the chart dates before the first known market price of the symbol + let lastActivityUnitPrice: Big | undefined; + let lastMarketPrice: Big | undefined; + + const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; + + for (const order of ordersWithMarketPrices) { + ordersByDate[order.date] = ordersByDate[order.date] ?? []; + ordersByDate[order.date].push(order); + } + + for (const dateString of chartDates) { + if (dateString < startDateString) { + continue; + } else if (dateString > endDateString) { + break; + } + + const ordersOfDate = ordersByDate[dateString]; + + if (!lastMarketPrice && ordersOfDate?.length > 0) { + for (const { itemType, type, unitPrice } of ordersOfDate) { + if (!itemType && ['BUY', 'SELL'].includes(type)) { + lastActivityUnitPrice = unitPrice; + } + } + } + + const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; + + const unitPrice = + marketPrice ?? + lastMarketPrice ?? + lastActivityUnitPrice ?? + unitPriceAtEndDate; + + if (ordersOfDate?.length > 0) { + for (const order of ordersOfDate) { + order.unitPriceFromMarketData = unitPrice; + } + } else if (dateString >= dateStringOfFirstActivity) { + ordersWithMarketPrices.push({ + assetProfile, + unitPrice, + date: dateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + type: 'BUY', + unitPriceFromMarketData: unitPrice + }); + } + + if (marketPrice) { + lastMarketPrice = marketPrice; + } + } + + // Sort orders so that the start and end placeholder order are at the correct + // position + return sortBy(ordersWithMarketPrices, ({ date, itemType }) => { + let sortIndex = new Date(date); + + if (itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + return sortIndex.getTime(); + }); + } + public async getPerformance({ end, start }) { await this.snapshotPromise; @@ -939,10 +1103,84 @@ export abstract class PortfolioCalculator { return min(dates); } + protected getTotalsFromActivities({ + exchangeRates, + orders + }: { + exchangeRates: { [dateString: string]: number }; + orders: PortfolioOrder[]; + }) { + let totalDividend = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalLiabilitiesInBaseCurrency = new Big(0); + + for (const order of orders) { + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + if (order.type === 'DIVIDEND') { + const dividend = order.quantity.mul(order.unitPrice); + + totalDividend = totalDividend.plus(dividend); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + } + + return { + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency + }; + } + public getTransactionPoints() { return this.transactionPoints; } + protected getUnitPriceAtEndDate({ + dataSource, + isCash, + marketPriceAtEndDate, + orders + }: { + dataSource: DataSource; + isCash: boolean; + marketPriceAtEndDate: Big; + orders: PortfolioOrder[]; + }): Big { + const latestActivity = orders.at(-1); + + if ( + dataSource === 'MANUAL' && + ['BUY', 'SELL'].includes(latestActivity?.type) && + latestActivity?.unitPrice && + !marketPriceAtEndDate + ) { + // For BUY / SELL activities with a MANUAL data source where no historical market price is available, + // the calculation should fall back to using the activity’s unit price. + return latestActivity.unitPrice; + } else if (isCash) { + return new Big(1); + } + + return marketPriceAtEndDate; + } + private getChartDateMap({ endDate, startDate, 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 9ebf757f98..873bffd4a8 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -16,18 +16,14 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Big } from 'big.js'; import { - addMilliseconds, differenceInDays, eachYearOfInterval, format, isBefore, isThisYear } from 'date-fns'; -import { sortBy } from 'lodash'; export class RoaiPortfolioCalculator extends PortfolioCalculator { - private chartDates: string[]; - protected calculateOverallPerformance( positions: PortfolioCalculatorHolding[] ): PortfolioSnapshot { @@ -126,7 +122,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } protected getHoldingPerformance({ - chartDateMap, + chartDates, dataSource, end, exchangeRates, @@ -134,7 +130,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { start, symbol }: { - chartDateMap: { [date: string]: boolean }; + chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { @@ -173,14 +169,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { [date: string]: Big; } = {}; - let totalDividend = new Big(0); - let totalDividendInBaseCurrency = new Big(0); - let totalInterestInBaseCurrency = new Big(0); let totalInvestment = new Big(0); let totalInvestmentFromBuyTransactions = new Big(0); let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); let totalInvestmentWithCurrencyEffect = new Big(0); - let totalLiabilitiesInBaseCurrency = new Big(0); let totalQuantity = new Big(0); let totalQuantityFromBuyTransactions = new Big(0); let valueAtStartDate: Big; @@ -191,77 +183,26 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { symbol }); - // Copy the items as they are enriched below. A shallow copy is sufficient - // because only top-level properties are written. - let orders: PortfolioOrderItem[] = ( - this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [] - ).map((activity) => { - return { ...activity }; - }); + let orders: PortfolioOrderItem[] = + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH'; if (orders.length <= 0) { - return { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffectMap: {}, - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netPerformanceWithCurrencyEffectMap: {}, - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalDividend: new Big(0), - totalDividendInBaseCurrency: new Big(0), - totalInterestInBaseCurrency: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0), - totalLiabilitiesInBaseCurrency: new Big(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. - for (const order of orders) { - const exchangeRateAtOrderDate = exchangeRates[order.date]; - - if (order.type === 'DIVIDEND') { - const dividend = order.quantity.mul(order.unitPrice); - - totalDividend = totalDividend.plus(dividend); - totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( - dividend.mul(exchangeRateAtOrderDate ?? 1) - ); - } else if (order.type === 'INTEREST') { - const interest = order.quantity.mul(order.unitPrice); - - totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( - interest.mul(exchangeRateAtOrderDate ?? 1) - ); - } else if (order.type === 'LIABILITY') { - const liabilities = order.quantity.mul(order.unitPrice); - - totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( - liabilities.mul(exchangeRateAtOrderDate ?? 1) - ); - } - } + const { + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency + } = this.getTotalsFromActivities({ exchangeRates, orders }); - const dateStringOfFirstActivity = orders[0].date; - const dateOfFirstActivity = parseDate(dateStringOfFirstActivity); + const dateOfFirstActivity = parseDate(orders[0].date); const endDateString = format(end, DATE_FORMAT); const startDateString = format(start, DATE_FORMAT); @@ -269,23 +210,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[assetProfileIdentifier]; - let unitPriceAtEndDate = - marketSymbolMap[endDateString]?.[assetProfileIdentifier]; - - const latestActivity = orders.at(-1); - - if ( - dataSource === 'MANUAL' && - ['BUY', 'SELL'].includes(latestActivity?.type) && - latestActivity?.unitPrice && - !unitPriceAtEndDate - ) { - // For BUY / SELL activities with a MANUAL data source where no historical market price is available, - // the calculation should fall back to using the activity’s unit price. - unitPriceAtEndDate = latestActivity.unitPrice; - } else if (isCash) { - unitPriceAtEndDate = new Big(1); - } + const unitPriceAtEndDate = this.getUnitPriceAtEndDate({ + dataSource, + isCash, + orders, + marketPriceAtEndDate: + marketSymbolMap[endDateString]?.[assetProfileIdentifier] + }); if ( !unitPriceAtEndDate || @@ -299,139 +230,28 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }); return { + ...this.getEmptyHoldingPerformance(), totalDividend, totalDividendInBaseCurrency, totalInterestInBaseCurrency, totalLiabilitiesInBaseCurrency, - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: hasActivitiesWithQuantity, - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffectMap: {}, - netPerformanceWithCurrencyEffectMap: {}, - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0) + hasErrors: hasActivitiesWithQuantity }; } - const assetProfile: PortfolioOrderItem['assetProfile'] = { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }; - - // Add a synthetic order at the start and the end date - orders.push({ - assetProfile, - date: startDateString, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - itemType: 'start', - quantity: new Big(0), - type: 'BUY', - unitPrice: unitPriceAtStartDate - }); - - orders.push({ - assetProfile, - date: endDateString, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - itemType: 'end', - quantity: new Big(0), - type: 'BUY', - unitPrice: unitPriceAtEndDate - }); - - // Fall back to the unit price of the most recent BUY / SELL activity for - // the chart dates before the first known market price of the symbol - let lastActivityUnitPrice: Big | undefined; - let lastMarketPrice: Big | undefined; - - const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; - - for (const order of orders) { - ordersByDate[order.date] = ordersByDate[order.date] ?? []; - ordersByDate[order.date].push(order); - } - - if (!this.chartDates) { - this.chartDates = Object.keys(chartDateMap).sort(); - } - - for (const dateString of this.chartDates) { - if (dateString < startDateString) { - continue; - } else if (dateString > endDateString) { - break; - } - - const ordersOfDate = ordersByDate[dateString]; - - if (!lastMarketPrice && ordersOfDate?.length > 0) { - for (const { itemType, type, unitPrice } of ordersOfDate) { - if (!itemType && ['BUY', 'SELL'].includes(type)) { - lastActivityUnitPrice = unitPrice; - } - } - } - - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; - - if (ordersOfDate?.length > 0) { - for (const order of ordersOfDate) { - order.unitPriceFromMarketData = unitPrice; - } - } else if (dateString >= dateStringOfFirstActivity) { - orders.push({ - assetProfile, - unitPrice, - date: dateString, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - quantity: new Big(0), - type: 'BUY', - unitPriceFromMarketData: unitPrice - }); - } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } - } - - // Sort orders so that the start and end placeholder order are at the correct - // position - orders = sortBy(orders, ({ date, itemType }) => { - let sortIndex = new Date(date); - - if (itemType === 'end') { - sortIndex = addMilliseconds(sortIndex, 1); - } else if (itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); + orders = this.getOrdersWithMarketPrices({ + chartDates, + endDateString, + marketSymbolMap, + orders, + startDateString, + unitPriceAtEndDate, + unitPriceAtStartDate, + assetProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined } - - return sortIndex.getTime(); }); const indexOfStartOrder = orders.findIndex(({ itemType }) => { @@ -901,8 +721,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let average = new Big(0); let dayCount = 0; - for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { - const date = this.chartDates[i]; + for (let i = chartDates.length - 1; i >= 0; i -= 1) { + const date = chartDates[i]; if (date > rangeEndDateString) { continue; 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 b3fc92b123..d31848a2be 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -11,7 +11,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { } protected getHoldingPerformance({}: { - chartDateMap: { [date: string]: boolean }; + chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { 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 fc13a5ec14..b577070c34 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -11,7 +11,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator { } protected getHoldingPerformance({}: { - chartDateMap: { [date: string]: boolean }; + chartDates: string[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: {