From 67b29814b14338f296789c6a2a7fd6a926b1530b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:10:21 +0100 Subject: [PATCH] Integrate dividend into transaction point concept --- apps/api/src/app/order/order.service.ts | 2 + .../transaction-point-symbol.interface.ts | 1 + ...folio-calculator-baln-buy-and-sell.spec.ts | 2 + .../portfolio-calculator-baln-buy.spec.ts | 2 + ...ator-btcusd-buy-and-sell-partially.spec.ts | 2 + .../portfolio-calculator-googl-buy.spec.ts | 2 + ...ulator-novn-buy-and-sell-partially.spec.ts | 2 + ...folio-calculator-novn-buy-and-sell.spec.ts | 2 + .../src/app/portfolio/portfolio-calculator.ts | 74 ++++++++++++------- .../src/app/portfolio/portfolio.controller.ts | 22 +++++- .../src/app/portfolio/portfolio.service.ts | 64 ++++++---------- .../portfolio-summary.component.html | 2 +- .../portfolio-position.interface.ts | 1 + .../interfaces/portfolio-summary.interface.ts | 2 +- .../interfaces/symbol-metrics.interface.ts | 2 + .../interfaces/timeline-position.interface.ts | 2 + 16 files changed, 113 insertions(+), 71 deletions(-) diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index d200ee024..c97243929 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -340,11 +340,13 @@ export class OrderService { return { ...order, value, + // TODO: Use exchange rate of date feeInBaseCurrency: this.exchangeRateDataService.toCurrency( order.fee, order.SymbolProfile.currency, userCurrency ), + // TODO: Use exchange rate of date valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value, order.SymbolProfile.currency, diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index 5350adccc..5f8e64406 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -4,6 +4,7 @@ import Big from 'big.js'; export interface TransactionPointSymbol { currency: string; dataSource: DataSource; + dividend: Big; fee: Big; firstBuyDate: string; investment: Big; diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts index d81393719..9831e5b00 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts index e77335ab8..bb5986059 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts @@ -96,6 +96,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('136.6'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('1.55'), firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 8f5573928..6126311c3 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -120,6 +120,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('320.43'), currency: 'USD', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('0'), firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts index 502248388..d29498292 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts @@ -109,6 +109,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('89.12'), currency: 'USD', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('1'), firstBuyDate: '2023-01-03', grossPerformance: new Big('27.33'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index c46fd54d2..afddc5423 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('75.80'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('4.25'), firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts index fa3ebac9b..4b7750a63 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -133,6 +133,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('0'), firstBuyDate: '2022-03-07', grossPerformance: new Big('19.86'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 27a5a278b..2bad796d9 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -70,6 +70,7 @@ export class PortfolioCalculator { let lastDate: string = null; let lastTransactionPoint: TransactionPoint = null; + for (const order of this.orders) { const currentDate = order.date; @@ -78,12 +79,13 @@ export class PortfolioCalculator { const factor = getFactor(order.type); const unitPrice = new Big(order.unitPrice); + if (oldAccumulatedSymbol) { const newQuantity = order.quantity .mul(factor) .plus(oldAccumulatedSymbol.quantity); - let investment = new Big(0); + let investment = oldAccumulatedSymbol.investment; if (newQuantity.gt(0)) { if (order.type === 'BUY') { @@ -104,6 +106,7 @@ export class PortfolioCalculator { investment, currency: order.currency, dataSource: order.dataSource, + dividend: new Big(0), fee: order.fee.plus(oldAccumulatedSymbol.fee), firstBuyDate: oldAccumulatedSymbol.firstBuyDate, quantity: newQuantity, @@ -115,6 +118,7 @@ export class PortfolioCalculator { currentTransactionPointItem = { currency: order.currency, dataSource: order.dataSource, + dividend: new Big(0), fee: order.fee, firstBuyDate: order.date, investment: unitPrice.mul(order.quantity).mul(factor), @@ -583,6 +587,8 @@ export class PortfolioCalculator { ); const { + dividend, + dividendInBaseCurrency, grossPerformance, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, @@ -608,6 +614,8 @@ export class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; positions.push({ + dividend, + dividendInBaseCurrency, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, averagePrice: item.quantity.eq(0) @@ -842,6 +850,8 @@ export class PortfolioCalculator { const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentValues: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let dividend = new Big(0); + let dividendInBaseCurrency = new Big(0); let fees = new Big(0); let feesAtStartDate = new Big(0); let feesAtStartDateWithCurrencyEffect = new Big(0); @@ -894,6 +904,8 @@ export class PortfolioCalculator { return { currentValues: {}, currentValuesWithCurrencyEffect: {}, + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -934,6 +946,8 @@ export class PortfolioCalculator { return { currentValues: {}, currentValuesWithCurrencyEffect: {}, + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -1108,29 +1122,29 @@ export class PortfolioCalculator { valueOfInvestmentBeforeTransactionWithCurrencyEffect; } - const transactionInvestment = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrency) - .mul(getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(getFactor(order.type)) - : new Big(0); - - const transactionInvestmentWithCurrencyEffect = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) - .mul(getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestmentWithCurrencyEffect - .div(totalUnits) - .mul(order.quantity) - .mul(getFactor(order.type)) - : new Big(0); + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + + if (order.type === 'BUY') { + transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + } else if (order.type === 'SELL') { + if (totalUnits.gt(0)) { + transactionInvestment = totalInvestment + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + } if (PortfolioCalculator.ENABLE_LOGGING) { console.log('totalInvestment', totalInvestment.toNumber()); @@ -1186,6 +1200,13 @@ export class PortfolioCalculator { totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); + if (order.type === 'DIVIDEND') { + dividend = dividend.plus(order.quantity.mul(order.unitPrice)); + dividendInBaseCurrency = dividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } + const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( @@ -1277,7 +1298,7 @@ export class PortfolioCalculator { grossPerformanceWithCurrencyEffect; } - if (i > indexOfStartOrder) { + if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) { // Only consider periods with an investment for the calculation of // the time weighted investment if (valueOfInvestmentBeforeTransaction.gt(0)) { @@ -1471,6 +1492,7 @@ export class PortfolioCalculator { Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( 2 )} + Total dividend: ${dividend.toFixed(2)} Gross performance: ${totalGrossPerformance.toFixed( 2 )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% @@ -1495,6 +1517,8 @@ export class PortfolioCalculator { return { currentValues, currentValuesWithCurrencyEffect, + dividend, + dividendInBaseCurrency, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, initialValue, diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index e51321561..63e26e512 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,5 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { @@ -11,6 +12,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { DEFAULT_CURRENCY, HEADER_KEY_IMPERSONATION @@ -57,6 +59,8 @@ export class PortfolioController { private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -161,7 +165,7 @@ export class PortfolioController { 'currentNetPerformance', 'currentNetPerformanceWithCurrencyEffect', 'currentValue', - 'dividend', + 'dividendInBaseCurrency', 'emergencyFund', 'excludedAccountsAndActivities', 'fees', @@ -231,11 +235,21 @@ export class PortfolioController { filterByTags }); + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const { activities } = await this.orderService.getOrders({ + filters, + userCurrency, + userId: impersonationUserId || this.request.user.id, + types: ['DIVIDEND'] + }); + let dividends = await this.portfolioService.getDividends({ + activities, dateRange, - filters, - groupBy, - impersonationId + groupBy }); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 0d14e35df..6f63916e1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -218,27 +218,14 @@ export class PortfolioService { } public async getDividends({ - dateRange, - filters, - groupBy, - impersonationId + activities, + dateRange = 'max', + groupBy }: { - dateRange: DateRange; - filters?: Filter[]; + activities: Activity[]; + dateRange?: DateRange; groupBy?: GroupBy; - impersonationId: string; }): Promise { - const userId = await this.getUserId(impersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - const userCurrency = this.getUserCurrency(user); - - const { activities } = await this.orderService.getOrders({ - filters, - userCurrency, - userId, - types: ['DIVIDEND'] - }); - let dividends = activities.map(({ date, valueInBaseCurrency }) => { return { date: format(date, DATE_FORMAT), @@ -441,6 +428,7 @@ export class PortfolioService { for (const { currency, + dividend, firstBuyDate, grossPerformance, grossPerformanceWithCurrencyEffect, @@ -553,6 +541,7 @@ export class PortfolioService { countries: symbolProfile.countries, dataSource: symbolProfile.dataSource, dateOfFirstActivity: parseDate(firstBuyDate), + dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, grossPerformancePercentWithCurrencyEffect: @@ -723,7 +712,7 @@ export class PortfolioService { .filter((order) => { tags = tags.concat(order.tags); - return ['BUY', 'ITEM', 'SELL'].includes(order.type); + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); }) .map((order) => ({ currency: order.SymbolProfile.currency, @@ -764,6 +753,8 @@ export class PortfolioService { averagePrice, currency, dataSource, + dividend, + dividendInBaseCurrency, fee, firstBuyDate, marketPrice, @@ -780,16 +771,6 @@ export class PortfolioService { return Account; }); - const dividendInBaseCurrency = getSum( - orders - .filter(({ type }) => { - return type === 'DIVIDEND'; - }) - .map(({ valueInBaseCurrency }) => { - return new Big(valueInBaseCurrency); - }) - ); - const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol: aSymbol }], 'day', @@ -1636,6 +1617,7 @@ export class PortfolioService { countries: [], dataSource: undefined, dateOfFirstActivity: undefined, + dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, @@ -1765,11 +1747,16 @@ export class PortfolioService { } } - const dividend = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'DIVIDEND' - }).toNumber(); + const dividendInBaseCurrency = ( + await this.getDividends({ + activities: activities.filter(({ type }) => { + return type === 'DIVIDEND'; + }) + }) + ).reduce( + (previous, current) => new Big(previous).plus(current.investment), + new Big(0) + ); const emergencyFund = new Big( Math.max( @@ -1904,7 +1891,6 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, - dividend, excludedAccountsAndActivities, fees, firstOrderDate, @@ -1915,6 +1901,7 @@ export class PortfolioService { totalBuy, totalSell, committedFunds: committedFunds.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundPositionsValueInBaseCurrency, cash: emergencyFund @@ -1967,7 +1954,7 @@ export class PortfolioService { private async getTransactionPoints({ filters, includeDrafts = false, - types = ['BUY', 'ITEM', 'LIABILITY', 'SELL'], + types = ['BUY', 'DIVIDEND', 'ITEM', 'LIABILITY', 'SELL'], userId, withExcludedAccounts = false }: { @@ -2144,14 +2131,11 @@ export class PortfolioService { type } of ordersByAccount) { let currentValueOfSymbolInBaseCurrency = + getFactor(type) * quantity * (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? 0); - if (['LIABILITY', 'SELL'].includes(type)) { - currentValueOfSymbolInBaseCurrency *= getFactor(type); - } - if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency; diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index c4aea8891..096271926 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -328,7 +328,7 @@ [isCurrency]="true" [locale]="locale" [unit]="baseCurrency" - [value]="isLoading ? undefined : summary?.dividend" + [value]="isLoading ? undefined : summary?.dividendInBaseCurrency" /> diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index 87db0117d..2e5772ef7 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -14,6 +14,7 @@ export interface PortfolioPosition { currency: string; dataSource: DataSource; dateOfFirstActivity: Date; + dividend: number; exchange?: string; grossPerformance: number; grossPerformancePercent: number; diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index f0bb4c3b1..aaf80c0cd 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -5,7 +5,7 @@ export interface PortfolioSummary extends PortfolioPerformance { annualizedPerformancePercentWithCurrencyEffect: number; cash: number; committedFunds: number; - dividend: number; + dividendInBaseCurrency: number; emergencyFund: { assets: number; cash: number; diff --git a/libs/common/src/lib/interfaces/symbol-metrics.interface.ts b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts index e7cbf7460..c269810d8 100644 --- a/libs/common/src/lib/interfaces/symbol-metrics.interface.ts +++ b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts @@ -7,6 +7,8 @@ export interface SymbolMetrics { currentValuesWithCurrencyEffect: { [date: string]: Big; }; + dividend: Big; + dividendInBaseCurrency: Big; grossPerformance: Big; grossPerformancePercentage: Big; grossPerformancePercentageWithCurrencyEffect: Big; diff --git a/libs/common/src/lib/interfaces/timeline-position.interface.ts b/libs/common/src/lib/interfaces/timeline-position.interface.ts index 220a0aa8f..831c29b31 100644 --- a/libs/common/src/lib/interfaces/timeline-position.interface.ts +++ b/libs/common/src/lib/interfaces/timeline-position.interface.ts @@ -5,6 +5,8 @@ export interface TimelinePosition { averagePrice: Big; currency: string; dataSource: DataSource; + dividend: Big; + dividendInBaseCurrency: Big; fee: Big; firstBuyDate: string; grossPerformance: Big;