diff --git a/CHANGELOG.md b/CHANGELOG.md index 344ad73ed..7da5c3ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Moved the dividend calculations into the portfolio calculator +- Moved the fee calculations into the portfolio calculator +- Moved the interest calculations into the portfolio calculator + ## 2.72.0 - 2024-04-13 ### Added 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 ec744f624..978f1f3aa 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -1,5 +1,5 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; +import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; import { SymbolMetrics, TimelinePosition, @@ -9,7 +9,7 @@ import { export class MWRPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance( positions: TimelinePosition[] - ): CurrentPositions { + ): PortfolioSnapshot { throw new Error('Method not implemented.'); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts index cf1fe9324..e64c23942 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -1,6 +1,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DateRange } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -23,17 +24,20 @@ export class PortfolioCalculatorFactory { public createCalculator({ activities, calculationType, - currency + currency, + dateRange = 'max' }: { activities: Activity[]; calculationType: PerformanceCalculationType; currency: string; + dateRange?: DateRange; }): PortfolioCalculator { switch (calculationType) { case PerformanceCalculationType.MWR: return new MWRPortfolioCalculator({ activities, currency, + dateRange, currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService }); @@ -42,6 +46,7 @@ export class PortfolioCalculatorFactory { activities, currency, currentRateService: this.currentRateService, + dateRange, exchangeRateDataService: this.exchangeRateDataService }); default: diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index a9dbff442..712c0ac04 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,7 +1,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; +import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { @@ -11,7 +11,12 @@ import { import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getSum, + parseDate, + resetHours +} from '@ghostfolio/common/helper'; import { DataProviderInfo, HistoricalDataItem, @@ -44,18 +49,24 @@ export abstract class PortfolioCalculator { private currency: string; private currentRateService: CurrentRateService; private dataProviderInfos: DataProviderInfo[]; + private endDate: Date; private exchangeRateDataService: ExchangeRateDataService; + private snapshot: PortfolioSnapshot; + private snapshotPromise: Promise; + private startDate: Date; private transactionPoints: TransactionPoint[]; public constructor({ activities, currency, currentRateService, + dateRange, exchangeRateDataService }: { activities: Activity[]; currency: string; currentRateService: CurrentRateService; + dateRange: DateRange; exchangeRateDataService: ExchangeRateDataService; }) { this.currency = currency; @@ -79,12 +90,270 @@ export abstract class PortfolioCalculator { return a.date?.localeCompare(b.date); }); + const { endDate, startDate } = getInterval(dateRange); + + this.endDate = endDate; + this.startDate = startDate; + this.computeTransactionPoints(); + + this.snapshotPromise = this.initialize(); } protected abstract calculateOverallPerformance( positions: TimelinePosition[] - ): CurrentPositions; + ): PortfolioSnapshot; + + public async computeSnapshot( + start: Date, + end?: Date + ): Promise { + const lastTransactionPoint = last(this.transactionPoints); + + let endDate = end; + + if (!endDate) { + endDate = new Date(Date.now()); + + if (lastTransactionPoint) { + endDate = max([endDate, parseDate(lastTransactionPoint.date)]); + } + } + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), endDate); + }); + + if (!transactionPoints.length) { + return { + currentValueInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffect: new Big(0), + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0) + }; + } + + const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + let dates: Date[] = []; + let firstIndex = transactionPoints.length; + let firstTransactionPoint: TransactionPoint = null; + + dates.push(resetHours(start)); + + for (const { currency, dataSource, symbol } of transactionPoints[ + firstIndex - 1 + ].items) { + dataGatheringItems.push({ + dataSource, + symbol + }); + + currencies[symbol] = currency; + } + + for (let i = 0; i < transactionPoints.length; i++) { + if ( + !isBefore(parseDate(transactionPoints[i].date), start) && + firstTransactionPoint === null + ) { + firstTransactionPoint = transactionPoints[i]; + firstIndex = i; + } + + if (firstTransactionPoint !== null) { + dates.push(resetHours(parseDate(transactionPoints[i].date))); + } + } + + dates.push(resetHours(endDate)); + + // Add dates of last week for fallback + dates.push(subDays(resetHours(new Date()), 7)); + dates.push(subDays(resetHours(new Date()), 6)); + dates.push(subDays(resetHours(new Date()), 5)); + dates.push(subDays(resetHours(new Date()), 4)); + dates.push(subDays(resetHours(new Date()), 3)); + dates.push(subDays(resetHours(new Date()), 2)); + dates.push(subDays(resetHours(new Date()), 1)); + dates.push(resetHours(new Date())); + + dates = uniq( + dates.map((date) => { + return date.getTime(); + }) + ) + .map((timestamp) => { + return new Date(timestamp); + }) + .sort((a, b) => { + return a.getTime() - b.getTime(); + }); + + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(endDate), + startDate: this.getStartDate(), + targetCurrency: this.currency + }); + + const { + dataProviderInfos, + errors: currentRateErrors, + values: marketSymbols + } = await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery: { + in: dates + } + }); + + this.dataProviderInfos = dataProviderInfos; + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + const endDateString = format(endDate, DATE_FORMAT); + + if (firstIndex > 0) { + firstIndex--; + } + + const positions: TimelinePosition[] = []; + let hasAnySymbolMetricsErrors = false; + + const errors: ResponseError['errors'] = []; + + for (const item of lastTransactionPoint.items) { + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + endDateString + ] + ); + + const { + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + hasErrors, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + totalDividend, + totalDividendInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = this.getSymbolMetrics({ + marketSymbolMap, + start, + dataSource: item.dataSource, + end: endDate, + exchangeRates: + exchangeRatesByCurrency[`${item.currency}${this.currency}`], + symbol: item.symbol + }); + + hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; + + positions.push({ + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + averagePrice: item.averagePrice, + currency: item.currency, + dataSource: item.dataSource, + fee: item.fee, + firstBuyDate: item.firstBuyDate, + grossPerformance: !hasErrors ? grossPerformance ?? null : null, + grossPerformancePercentage: !hasErrors + ? grossPerformancePercentage ?? null + : null, + grossPerformancePercentageWithCurrencyEffect: !hasErrors + ? grossPerformancePercentageWithCurrencyEffect ?? null + : null, + grossPerformanceWithCurrencyEffect: !hasErrors + ? grossPerformanceWithCurrencyEffect ?? null + : null, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + marketPrice: + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, + marketPriceInBaseCurrency: + marketPriceInBaseCurrency?.toNumber() ?? null, + netPerformance: !hasErrors ? netPerformance ?? null : null, + netPerformancePercentage: !hasErrors + ? netPerformancePercentage ?? null + : null, + netPerformancePercentageWithCurrencyEffect: !hasErrors + ? netPerformancePercentageWithCurrencyEffect ?? null + : null, + netPerformanceWithCurrencyEffect: !hasErrors + ? netPerformanceWithCurrencyEffect ?? null + : null, + quantity: item.quantity, + symbol: item.symbol, + tags: item.tags, + transactionCount: item.transactionCount, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) + }); + + if ( + (hasErrors || + currentRateErrors.find(({ dataSource, symbol }) => { + return dataSource === item.dataSource && symbol === item.symbol; + })) && + item.investment.gt(0) + ) { + errors.push({ dataSource: item.dataSource, symbol: item.symbol }); + } + } + + const overall = this.calculateOverallPerformance(positions); + + return { + ...overall, + errors, + positions, + hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors, + totalInterestWithCurrencyEffect: lastTransactionPoint.interest + }; + } public async getChart({ dateRange = 'max', @@ -380,256 +649,30 @@ export abstract class PortfolioCalculator { }); } - public async getCurrentPositions( - start: Date, - end?: Date - ): Promise { - const lastTransactionPoint = last(this.transactionPoints); - - let endDate = end; - - if (!endDate) { - endDate = new Date(Date.now()); - - if (lastTransactionPoint) { - endDate = max([endDate, parseDate(lastTransactionPoint.date)]); - } - } - - const transactionPoints = this.transactionPoints?.filter(({ date }) => { - return isBefore(parseDate(date), endDate); - }); - - if (!transactionPoints.length) { - return { - currentValueInBaseCurrency: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceWithCurrencyEffect: new Big(0), - positions: [], - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0) - }; - } - - const currencies: { [symbol: string]: string } = {}; - const dataGatheringItems: IDataGatheringItem[] = []; - let dates: Date[] = []; - let firstIndex = transactionPoints.length; - let firstTransactionPoint: TransactionPoint = null; - - dates.push(resetHours(start)); - - for (const { currency, dataSource, symbol } of transactionPoints[ - firstIndex - 1 - ].items) { - dataGatheringItems.push({ - dataSource, - symbol - }); - - currencies[symbol] = currency; - } - - for (let i = 0; i < transactionPoints.length; i++) { - if ( - !isBefore(parseDate(transactionPoints[i].date), start) && - firstTransactionPoint === null - ) { - firstTransactionPoint = transactionPoints[i]; - firstIndex = i; - } - - if (firstTransactionPoint !== null) { - dates.push(resetHours(parseDate(transactionPoints[i].date))); - } - } - - dates.push(resetHours(endDate)); + public getDataProviderInfos() { + return this.dataProviderInfos; + } - // Add dates of last week for fallback - dates.push(subDays(resetHours(new Date()), 7)); - dates.push(subDays(resetHours(new Date()), 6)); - dates.push(subDays(resetHours(new Date()), 5)); - dates.push(subDays(resetHours(new Date()), 4)); - dates.push(subDays(resetHours(new Date()), 3)); - dates.push(subDays(resetHours(new Date()), 2)); - dates.push(subDays(resetHours(new Date()), 1)); - dates.push(resetHours(new Date())); + public async getDividendInBaseCurrency() { + await this.snapshotPromise; - dates = uniq( - dates.map((date) => { - return date.getTime(); - }) - ) - .map((timestamp) => { - return new Date(timestamp); + return getSum( + this.snapshot.positions.map(({ dividendInBaseCurrency }) => { + return dividendInBaseCurrency; }) - .sort((a, b) => { - return a.getTime() - b.getTime(); - }); - - let exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: uniq(Object.values(currencies)), - endDate: endOfDay(endDate), - startDate: this.getStartDate(), - targetCurrency: this.currency - }); - - const { - dataProviderInfos, - errors: currentRateErrors, - values: marketSymbols - } = await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - - this.dataProviderInfos = dataProviderInfos; - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - for (const marketSymbol of marketSymbols) { - const date = format(marketSymbol.date, DATE_FORMAT); - - if (!marketSymbolMap[date]) { - marketSymbolMap[date] = {}; - } - - if (marketSymbol.marketPrice) { - marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - - const endDateString = format(endDate, DATE_FORMAT); - - if (firstIndex > 0) { - firstIndex--; - } - - const positions: TimelinePosition[] = []; - let hasAnySymbolMetricsErrors = false; - - const errors: ResponseError['errors'] = []; - - for (const item of lastTransactionPoint.items) { - const marketPriceInBaseCurrency = ( - marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice - ).mul( - exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ - endDateString - ] - ); - - const { - grossPerformance, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - grossPerformanceWithCurrencyEffect, - hasErrors, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - totalDividend, - totalDividendInBaseCurrency, - totalInvestment, - totalInvestmentWithCurrencyEffect - } = this.getSymbolMetrics({ - marketSymbolMap, - start, - dataSource: item.dataSource, - end: endDate, - exchangeRates: - exchangeRatesByCurrency[`${item.currency}${this.currency}`], - symbol: item.symbol - }); - - hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - - positions.push({ - dividend: totalDividend, - dividendInBaseCurrency: totalDividendInBaseCurrency, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - averagePrice: item.averagePrice, - currency: item.currency, - dataSource: item.dataSource, - fee: item.fee, - firstBuyDate: item.firstBuyDate, - grossPerformance: !hasErrors ? grossPerformance ?? null : null, - grossPerformancePercentage: !hasErrors - ? grossPerformancePercentage ?? null - : null, - grossPerformancePercentageWithCurrencyEffect: !hasErrors - ? grossPerformancePercentageWithCurrencyEffect ?? null - : null, - grossPerformanceWithCurrencyEffect: !hasErrors - ? grossPerformanceWithCurrencyEffect ?? null - : null, - investment: totalInvestment, - investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - marketPrice: - marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, - marketPriceInBaseCurrency: - marketPriceInBaseCurrency?.toNumber() ?? null, - netPerformance: !hasErrors ? netPerformance ?? null : null, - netPerformancePercentage: !hasErrors - ? netPerformancePercentage ?? null - : null, - netPerformancePercentageWithCurrencyEffect: !hasErrors - ? netPerformancePercentageWithCurrencyEffect ?? null - : null, - netPerformanceWithCurrencyEffect: !hasErrors - ? netPerformanceWithCurrencyEffect ?? null - : null, - quantity: item.quantity, - symbol: item.symbol, - tags: item.tags, - transactionCount: item.transactionCount, - valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( - item.quantity - ) - }); - - if ( - (hasErrors || - currentRateErrors.find(({ dataSource, symbol }) => { - return dataSource === item.dataSource && symbol === item.symbol; - })) && - item.investment.gt(0) - ) { - errors.push({ dataSource: item.dataSource, symbol: item.symbol }); - } - } + ); + } - const overall = this.calculateOverallPerformance(positions); + public async getFeesInBaseCurrency() { + await this.snapshotPromise; - return { - ...overall, - errors, - positions, - hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors - }; + return this.snapshot.totalFeesWithCurrencyEffect; } - public getDataProviderInfos() { - return this.dataProviderInfos; + public async getInterestInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalInterestWithCurrencyEffect; } public getInvestments(): { date: string; investment: Big }[] { @@ -672,6 +715,12 @@ export abstract class PortfolioCalculator { })); } + public async getSnapshot() { + await this.snapshotPromise; + + return this.snapshot; + } + public getStartDate() { return this.transactionPoints.length > 0 ? parseDate(this.transactionPoints[0].date) @@ -718,6 +767,13 @@ export abstract class PortfolioCalculator { type, unitPrice } of this.orders) { + if ( + // TODO + ['ITEM', 'LIABILITY'].includes(type) + ) { + continue; + } + let currentTransactionPointItem: TransactionPointSymbol; const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; @@ -790,18 +846,39 @@ export abstract class PortfolioCalculator { return a.symbol?.localeCompare(b.symbol); }); + let fees = new Big(0); + + if (type === 'FEE') { + fees = fee; + } + + let interest = new Big(0); + + if (type === 'INTEREST') { + interest = quantity.mul(unitPrice); + } + if (lastDate !== date || lastTransactionPoint === null) { lastTransactionPoint = { date, + fees, + interest, items: newItems }; this.transactionPoints.push(lastTransactionPoint); } else { + lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees); + lastTransactionPoint.interest = + lastTransactionPoint.interest.plus(interest); lastTransactionPoint.items = newItems; } lastDate = date; } } + + private async initialize() { + this.snapshot = await this.computeSnapshot(this.startDate, this.endDate); + } } diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index b936d21a9..8ddae9df6 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -46,6 +46,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with BALN.SW buy and sell in two activities', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -100,15 +104,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2021-11-22') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2021-11-22') ); @@ -121,7 +121,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('-12.6'), @@ -173,6 +173,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('0') } ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts index d1557bc12..febd1769d 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -46,6 +46,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with BALN.SW buy and sell', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -85,15 +89,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2021-11-22') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2021-11-22') ); @@ -106,7 +106,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('-12.6'), @@ -156,6 +156,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('0') } ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts index 593503493..2b9fd06f0 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -46,6 +46,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with BALN.SW buy', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -70,15 +74,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2021-11-30') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2021-11-30') ); @@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('297.8'), errors: [], grossPerformance: new Big('24.6'), @@ -141,6 +141,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('297.8') } ], + totalFeesWithCurrencyEffect: new Big('1.55'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('273.2'), totalInvestmentWithCurrencyEffect: new Big('273.2') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index e3f351b28..ceff92449 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -59,6 +59,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with BTCUSD buy and sell partially', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2018-01-01').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -98,15 +102,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2018-01-01').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2015-01-01') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2015-01-01') ); @@ -119,7 +119,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('13298.425356'), errors: [], grossPerformance: new Big('27172.74'), @@ -175,6 +175,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('13298.425356') } ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('320.43'), totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts new file mode 100644 index 000000000..b689a0c30 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -0,0 +1,132 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with fee activity', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-01'), + fee: 49, + quantity: 0, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Account Opening Fee', + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' + }, + type: 'FEE', + unitPrice: 0 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD' + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( + parseDate('2021-11-30') + ); + + spy.mockRestore(); + + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + grossPerformance: new Big('0'), + grossPerformancePercentage: new Big('0'), + grossPerformancePercentageWithCurrencyEffect: new Big('0'), + grossPerformanceWithCurrencyEffect: new Big('0'), + hasErrors: true, + netPerformance: new Big('0'), + netPerformancePercentage: new Big('0'), + netPerformancePercentageWithCurrencyEffect: new Big('0'), + netPerformanceWithCurrencyEffect: new Big('0'), + positions: [ + { + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'MANUAL', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('49'), + firstBuyDate: '2021-09-01', + grossPerformance: null, + grossPerformancePercentage: null, + grossPerformancePercentageWithCurrencyEffect: null, + grossPerformanceWithCurrencyEffect: null, + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + marketPrice: null, + marketPriceInBaseCurrency: 0, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffect: null, + netPerformanceWithCurrencyEffect: null, + quantity: new Big('0'), + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141', + tags: [], + timeWeightedInvestment: new Big('0'), + timeWeightedInvestmentWithCurrencyEffect: new Big('0'), + transactionCount: 1, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('49'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts index e7796b4d3..911167f7a 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -59,6 +59,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with GOOGL buy', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -83,15 +87,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2023-07-10').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2023-01-03') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2023-01-03') ); @@ -104,7 +104,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('103.10483'), errors: [], grossPerformance: new Big('27.33'), @@ -154,6 +154,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('103.10483') } ], + totalFeesWithCurrencyEffect: new Big('1'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('89.12'), totalInvestmentWithCurrencyEffect: new Big('82.329056') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts index 49a07e73f..6dc489c5d 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -59,6 +59,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with MSFT buy', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -98,17 +102,13 @@ describe('PortfolioCalculator', () => { currency: 'USD' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2023-07-10').getTime()); - - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2023-07-10') ); spy.mockRestore(); - expect(currentPositions).toMatchObject({ + expect(portfolioSnapshot).toMatchObject({ errors: [], hasErrors: false, positions: [ @@ -130,6 +130,8 @@ describe('PortfolioCalculator', () => { transactionCount: 2 } ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('298.58'), totalInvestmentWithCurrencyEffect: new Big('298.58') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts index 905747519..ece39c87b 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -42,22 +42,22 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it('with no orders', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + const portfolioCalculator = factory.createCalculator({ activities: [], calculationType: PerformanceCalculationType.TWR, currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const start = subDays(new Date(Date.now()), 10); const chartData = await portfolioCalculator.getChartData({ start }); - const currentPositions = - await portfolioCalculator.getCurrentPositions(start); + const portfolioSnapshot = + await portfolioCalculator.computeSnapshot(start); const investments = portfolioCalculator.getInvestments(); @@ -68,7 +68,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), @@ -80,6 +80,8 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffect: new Big(0), netPerformanceWithCurrencyEffect: new Big(0), positions: [], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0) }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 2bfd6d865..a3c12829a 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -46,6 +46,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with NOVN.SW buy and sell partially', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-04-11').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -84,15 +88,12 @@ describe('PortfolioCalculator', () => { calculationType: PerformanceCalculationType.TWR, currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); const chartData = await portfolioCalculator.getChartData({ start: parseDate('2022-03-07') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2022-03-07') ); @@ -105,7 +106,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('87.8'), errors: [], grossPerformance: new Big('21.93'), @@ -157,6 +158,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('87.8') } ], + totalFeesWithCurrencyEffect: new Big('4.25'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('75.80'), totalInvestmentWithCurrencyEffect: new Big('75.80') }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index be3f75dc2..f1bf56f11 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -46,6 +46,10 @@ describe('PortfolioCalculator', () => { describe('get current positions', () => { it.only('with NOVN.SW buy and sell', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-04-11').getTime()); + const activities: Activity[] = [ { ...activityDummyData, @@ -85,15 +89,11 @@ describe('PortfolioCalculator', () => { currency: 'CHF' }); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2022-03-07') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2022-03-07') ); @@ -132,7 +132,7 @@ describe('PortfolioCalculator', () => { valueWithCurrencyEffect: 0 }); - expect(currentPositions).toEqual({ + expect(portfolioSnapshot).toEqual({ currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('19.86'), @@ -182,6 +182,8 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('0') } ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0') }); 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 0fee9c5c7..b9b7fd900 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -1,6 +1,6 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; +import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { @@ -23,19 +23,27 @@ import { cloneDeep, first, last, sortBy } from 'lodash'; export class TWRPortfolioCalculator extends PortfolioCalculator { protected calculateOverallPerformance( positions: TimelinePosition[] - ): CurrentPositions { + ): PortfolioSnapshot { let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; let netPerformance = new Big(0); let netPerformanceWithCurrencyEffect = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + let totalInterestWithCurrencyEffect = new Big(0); let totalInvestment = new Big(0); let totalInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { + if (currentPosition.fee) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.fee + ); + } + if (currentPosition.valueInBaseCurrency) { currentValueInBaseCurrency = currentValueInBaseCurrency.plus( currentPosition.valueInBaseCurrency @@ -101,6 +109,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { hasErrors, netPerformance, netPerformanceWithCurrencyEffect, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, totalInvestment, totalInvestmentWithCurrencyEffect, netPerformancePercentage: totalTimeWeightedInvestment.eq(0) @@ -178,6 +188,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { let totalDividend = new Big(0); let totalDividendInBaseCurrency = new Big(0); + let totalInterest = new Big(0); + let totalInterestInBaseCurrency = new Big(0); let totalInvestment = new Big(0); let totalInvestmentFromBuyTransactions = new Big(0); let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); @@ -198,6 +210,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { return { currentValues: {}, currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -220,6 +233,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestmentWithCurrencyEffect: new Big(0), totalDividend: new Big(0), totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), totalInvestment: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0) }; @@ -240,6 +255,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { return { currentValues: {}, currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -262,6 +278,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestmentWithCurrencyEffect: new Big(0), totalDividend: new Big(0), totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), totalInvestment: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0) }; @@ -511,6 +529,13 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( dividend.mul(exchangeRateAtOrderDate ?? 1) ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterest = totalInterest.plus(interest); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); } const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); @@ -808,6 +833,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { return { currentValues, currentValuesWithCurrencyEffect, + feesWithCurrencyEffect, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, initialValue, @@ -823,6 +849,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestmentValuesWithCurrencyEffect, totalDividend, totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, totalInvestment, totalInvestmentWithCurrencyEffect, grossPerformance: totalGrossPerformance, diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts similarity index 82% rename from apps/api/src/app/portfolio/interfaces/current-positions.interface.ts rename to apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts index 308cc4037..b8cc904fa 100644 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts @@ -2,7 +2,7 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; import { Big } from 'big.js'; -export interface CurrentPositions extends ResponseError { +export interface PortfolioSnapshot extends ResponseError { currentValueInBaseCurrency: Big; grossPerformance: Big; grossPerformanceWithCurrencyEffect: Big; @@ -15,6 +15,8 @@ export interface CurrentPositions extends ResponseError { netPerformancePercentage: Big; netPerformancePercentageWithCurrencyEffect: Big; positions: TimelinePosition[]; + totalFeesWithCurrencyEffect: Big; + totalInterestWithCurrencyEffect: Big; totalInvestment: Big; totalInvestmentWithCurrencyEffect: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts index 178df3456..2f5218405 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts @@ -1,6 +1,10 @@ +import { Big } from 'big.js'; + import { TransactionPointSymbol } from './transaction-point-symbol.interface'; export interface TransactionPoint { date: string; + fees: Big; + interest: Big; items: TransactionPointSymbol[]; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index efb9318d7..7ee92e91c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -107,7 +107,8 @@ export class PortfolioController { dateRange, filters, impersonationId, - withLiabilities, + // TODO + // withLiabilities, withMarkets, userId: this.request.user.id, withSummary: true @@ -389,11 +390,9 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccountsParam = 'false', - @Query('withItems') withItemsParam = 'false' + @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' ): Promise { const withExcludedAccounts = withExcludedAccountsParam === 'true'; - const withItems = withItemsParam === 'true'; const hasReadRestrictedAccessPermission = this.userService.hasReadRestrictedAccessPermission({ @@ -412,7 +411,6 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - withItems, userId: this.request.user.id }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 198395e51..8714a15ec 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -23,12 +23,7 @@ import { EMERGENCY_FUND_TAG_ID, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { - DATE_FORMAT, - getAllActivityTypes, - getSum, - parseDate -} from '@ghostfolio/common/helper'; +import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { Accounts, EnhancedSymbolProfile, @@ -78,6 +73,7 @@ import { } from 'date-fns'; import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; +import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PerformanceCalculationType, PortfolioCalculatorFactory @@ -349,19 +345,8 @@ export class PortfolioService { (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ); - let types = getAllActivityTypes().filter((activityType) => { - return activityType !== 'FEE'; - }); - - if (withLiabilities === false) { - types = types.filter((activityType) => { - return activityType !== 'LIABILITY'; - }); - } - const { activities } = await this.orderService.getOrders({ filters, - types, userCurrency, userId, withExcludedAccounts @@ -369,16 +354,13 @@ export class PortfolioService { const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, + dateRange, calculationType: PerformanceCalculationType.TWR, currency: userCurrency }); - const { startDate } = getInterval( - dateRange, - portfolioCalculator.getStartDate() - ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + const { currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -388,10 +370,9 @@ export class PortfolioService { const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = - currentPositions.currentValueInBaseCurrency.plus( - cashDetails.balanceInBaseCurrency - ); + const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( + cashDetails.balanceInBaseCurrency + ); const isFilteredByAccount = filters?.some(({ type }) => { @@ -409,7 +390,7 @@ export class PortfolioService { let filteredValueInBaseCurrency = isFilteredByAccount ? totalValueInBaseCurrency - : currentPositions.currentValueInBaseCurrency; + : currentValueInBaseCurrency; if ( filters?.length === 0 || @@ -422,14 +403,12 @@ export class PortfolioService { ); } - const dataGatheringItems = currentPositions.positions.map( - ({ dataSource, symbol }) => { - return { - dataSource, - symbol - }; - } - ); + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }); const [dataProviderResponses, symbolProfiles] = await Promise.all([ this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), @@ -442,7 +421,7 @@ export class PortfolioService { } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - for (const position of currentPositions.positions) { + for (const position of positions) { portfolioItemsNow[position.symbol] = position; } @@ -465,7 +444,7 @@ export class PortfolioService { tags, transactionCount, valueInBaseCurrency - } of currentPositions.positions) { + } of positions) { if (isFilteredByClosedHoldings === true) { if (!quantity.eq(0)) { // Ignore positions with a quantity @@ -593,6 +572,7 @@ export class PortfolioService { filteredValueInBaseCurrency, holdings, impersonationId, + portfolioCalculator, userCurrency, userId, balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, @@ -605,10 +585,10 @@ export class PortfolioService { return { accounts, + hasErrors, holdings, platforms, - summary, - hasErrors: currentPositions.hasErrors + summary }; } @@ -681,10 +661,9 @@ export class PortfolioService { const portfolioStart = portfolioCalculator.getStartDate(); const transactionPoints = portfolioCalculator.getTransactionPoints(); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + const { positions } = await portfolioCalculator.getSnapshot(); - const position = currentPositions.positions.find(({ symbol }) => { + const position = positions.find(({ symbol }) => { return symbol === aSymbol; }); @@ -916,13 +895,12 @@ export class PortfolioService { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); - const { endDate, startDate } = getInterval(dateRange); + const { endDate } = getInterval(dateRange); const { activities } = await this.orderService.getOrders({ endDate, filters, userId, - types: ['BUY', 'SELL'], userCurrency: this.getUserCurrency() }); @@ -935,16 +913,14 @@ export class PortfolioService { const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, + dateRange, calculationType: PerformanceCalculationType.TWR, currency: this.request.user.Settings.settings.baseCurrency }); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate, - endDate - ); + let { hasErrors, positions } = await portfolioCalculator.getSnapshot(); - let positions = currentPositions.positions.filter(({ quantity }) => { + positions = positions.filter(({ quantity }) => { return !quantity.eq(0); }); @@ -983,7 +959,7 @@ export class PortfolioService { } return { - hasErrors: currentPositions.hasErrors, + hasErrors, positions: positions.map( ({ averagePrice, @@ -1050,15 +1026,13 @@ export class PortfolioService { filters, impersonationId, userId, - withExcludedAccounts = false, - withItems = false + withExcludedAccounts = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; - withItems?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1096,8 +1070,7 @@ export class PortfolioService { filters, userCurrency, userId, - withExcludedAccounts, - types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL'] + withExcludedAccounts }); if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { @@ -1123,6 +1096,7 @@ export class PortfolioService { const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, + dateRange, calculationType: PerformanceCalculationType.TWR, currency: userCurrency }); @@ -1140,7 +1114,7 @@ export class PortfolioService { netPerformancePercentageWithCurrencyEffect, netPerformanceWithCurrencyEffect, totalInvestment - } = await portfolioCalculator.getCurrentPositions(startDate, endDate); + } = await portfolioCalculator.getSnapshot(); let currentNetPerformance = netPerformance; @@ -1231,8 +1205,7 @@ export class PortfolioService { const { activities } = await this.orderService.getOrders({ userCurrency, - userId, - types: ['BUY', 'SELL'] + userId }); const portfolioCalculator = this.calculatorFactory.createCalculator({ @@ -1241,13 +1214,10 @@ export class PortfolioService { currency: this.request.user.Settings.settings.baseCurrency }); - const currentPositions = await portfolioCalculator.getCurrentPositions( - portfolioCalculator.getStartDate() - ); + let { totalFeesWithCurrencyEffect, positions, totalInvestment } = + await portfolioCalculator.getSnapshot(); - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) - ); + positions = positions.filter((item) => !item.quantity.eq(0)); const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; @@ -1309,8 +1279,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - currentPositions.totalInvestment.toNumber(), - this.getFees({ activities, userCurrency }).toNumber() + totalInvestment.toNumber(), + totalFeesWithCurrencyEffect.toNumber() ) ], userSettings @@ -1454,30 +1424,6 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); } - private getFees({ - activities, - userCurrency - }: { - activities: Activity[]; - userCurrency: string; - }) { - return getSum( - activities - .filter(({ isDraft }) => { - return isDraft === false; - }) - .map(({ fee, SymbolProfile }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - fee, - SymbolProfile.currency, - userCurrency - ) - ); - }) - ); - } - private getInitialCashPosition({ balance, currency @@ -1623,6 +1569,7 @@ export class PortfolioService { filteredValueInBaseCurrency, holdings, impersonationId, + portfolioCalculator, userCurrency, userId }: { @@ -1631,6 +1578,7 @@ export class PortfolioService { filteredValueInBaseCurrency: Big; holdings: PortfolioDetails['holdings']; impersonationId: string; + portfolioCalculator: PortfolioCalculator; userCurrency: string; userId: string; }): Promise { @@ -1659,17 +1607,8 @@ export class PortfolioService { } } - const dividendInBaseCurrency = getSum( - ( - await this.getDividends({ - activities: activities.filter(({ type }) => { - return type === 'DIVIDEND'; - }) - }) - ).map(({ investment }) => { - return new Big(investment); - }) - ); + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); const emergencyFund = new Big( Math.max( @@ -1678,15 +1617,13 @@ export class PortfolioService { ) ); - const fees = this.getFees({ activities, userCurrency }).toNumber(); - const firstOrderDate = activities[0]?.date; + const fees = await portfolioCalculator.getFeesInBaseCurrency(); - const interest = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'INTEREST' - }).toNumber(); + const firstOrderDate = portfolioCalculator.getStartDate(); + + const interest = await portfolioCalculator.getInterestInBaseCurrency(); + // TODO: Move to portfolio calculator const items = getSum( Object.keys(holdings) .filter((symbol) => { @@ -1701,6 +1638,7 @@ export class PortfolioService { }) ).toNumber(); + // TODO: Move to portfolio calculator const liabilities = getSum( Object.keys(holdings) .filter((symbol) => { @@ -1791,9 +1729,7 @@ export class PortfolioService { annualizedPerformancePercentWithCurrencyEffect, cash, excludedAccountsAndActivities, - fees, firstOrderDate, - interest, items, liabilities, totalBuy, @@ -1807,6 +1743,7 @@ export class PortfolioService { .toNumber(), total: emergencyFund.toNumber() }, + fees: fees.toNumber(), filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), filteredValueInPercentage: netWorth ? filteredValueInBaseCurrency.div(netWorth).toNumber() @@ -1814,6 +1751,7 @@ export class PortfolioService { fireWealth: new Big(performanceInformation.performance.currentValue) .minus(emergencyFundPositionsValueInBaseCurrency) .toNumber(), + interest: interest.toNumber(), ordersCount: activities.filter(({ type }) => { return type === 'BUY' || type === 'SELL'; }).length, diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 730f34bde..f762b2ad5 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/apps/api/src/helper/portfolio.helper.ts @@ -37,36 +37,48 @@ export function getInterval( aDateRange: DateRange, portfolioStart = new Date(0) ) { - let endDate = endOfDay(new Date()); + let endDate = endOfDay(new Date(Date.now())); let startDate = portfolioStart; switch (aDateRange) { case '1d': - startDate = max([startDate, subDays(resetHours(new Date()), 1)]); + startDate = max([ + startDate, + subDays(resetHours(new Date(Date.now())), 1) + ]); break; case 'mtd': startDate = max([ startDate, - subDays(startOfMonth(resetHours(new Date())), 1) + subDays(startOfMonth(resetHours(new Date(Date.now()))), 1) ]); break; case 'wtd': startDate = max([ startDate, - subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) + subDays( + startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }), + 1 + ) ]); break; case 'ytd': startDate = max([ startDate, - subDays(startOfYear(resetHours(new Date())), 1) + subDays(startOfYear(resetHours(new Date(Date.now()))), 1) ]); break; case '1y': - startDate = max([startDate, subYears(resetHours(new Date()), 1)]); + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 1) + ]); break; case '5y': - startDate = max([startDate, subYears(resetHours(new Date()), 5)]); + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 5) + ]); break; case 'max': break; diff --git a/libs/common/src/lib/interfaces/symbol-metrics.interface.ts b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts index ecb80ef92..99a1b3467 100644 --- a/libs/common/src/lib/interfaces/symbol-metrics.interface.ts +++ b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts @@ -7,6 +7,7 @@ export interface SymbolMetrics { currentValuesWithCurrencyEffect: { [date: string]: Big; }; + feesWithCurrencyEffect: Big; grossPerformance: Big; grossPerformancePercentage: Big; grossPerformancePercentageWithCurrencyEffect: Big; @@ -41,6 +42,8 @@ export interface SymbolMetrics { timeWeightedInvestmentWithCurrencyEffect: Big; totalDividend: Big; totalDividendInBaseCurrency: Big; + totalInterest: Big; + totalInterestInBaseCurrency: Big; totalInvestment: Big; totalInvestmentWithCurrencyEffect: Big; }