From 77358eed65ffcfb75ee0d00bd96649fa86d792ef Mon Sep 17 00:00:00 2001 From: Gerard Du Pre <37554513+GerardPolloRebozado@users.noreply.github.com> Date: Thu, 7 Mar 2024 19:38:57 +0100 Subject: [PATCH 1/6] Feature/Include user role in admin endpoint (#3107) * Include user role in admin endpoint --- apps/api/src/app/admin/admin.service.ts | 4 +++- libs/common/src/lib/interfaces/admin-data.interface.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 320601667..f78d1b61d 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -440,13 +440,14 @@ export class AdminService { }, createdAt: true, id: true, + role: true, Subscription: true }, take: 30 }); return usersWithAnalytics.map( - ({ _count, Analytics, createdAt, id, Subscription }) => { + ({ _count, Analytics, createdAt, id, role, Subscription }) => { const daysSinceRegistration = differenceInDays(new Date(), createdAt) + 1; const engagement = Analytics @@ -466,6 +467,7 @@ export class AdminService { createdAt, engagement, id, + role, subscription, accountCount: _count.Account || 0, country: Analytics?.country, diff --git a/libs/common/src/lib/interfaces/admin-data.interface.ts b/libs/common/src/lib/interfaces/admin-data.interface.ts index 6d51d5d52..2c6a5c501 100644 --- a/libs/common/src/lib/interfaces/admin-data.interface.ts +++ b/libs/common/src/lib/interfaces/admin-data.interface.ts @@ -1,3 +1,5 @@ +import { Role } from '@prisma/client'; + import { UniqueAsset } from './unique-asset.interface'; export interface AdminData { @@ -16,6 +18,7 @@ export interface AdminData { engagement: number; id: string; lastActivity: Date; + role: Role; transactionCount: number; }[]; version: string; From 07661d9262a6a926859b2ca7b9e75656086df126 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 7 Mar 2024 20:07:50 +0100 Subject: [PATCH 2/6] Feature/integrate dividend into transaction point concept (#3092) * Integrate dividend into transaction point concept * Update changelog --- CHANGELOG.md | 6 + apps/api/jest.config.ts | 1 - apps/api/src/app/order/order.service.ts | 2 + .../portfolio/current-rate.service.mock.ts | 9 ++ .../transaction-point-symbol.interface.ts | 2 + ...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 + ...-calculator-msft-buy-with-dividend.spec.ts | 118 ++++++++++++++++++ ...ulator-novn-buy-and-sell-partially.spec.ts | 2 + ...folio-calculator-novn-buy-and-sell.spec.ts | 2 + .../src/app/portfolio/portfolio-calculator.ts | 114 ++++++++++------- .../src/app/portfolio/portfolio.controller.ts | 22 +++- .../src/app/portfolio/portfolio.service.ts | 67 ++++------ .../exchange-rate-data.service.mock.ts | 7 ++ .../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 + 21 files changed, 275 insertions(+), 94 deletions(-) create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-msft-buy-with-dividend.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dce287c1..5da3b143b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +- Integrated dividend into the transaction point concept in the portfolio service + ## 2.61.1 - 2024-03-06 ### Fixed diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index 8152c3f2a..b87f91a79 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -13,7 +13,6 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/api', - testTimeout: 10000, testEnvironment: 'node', preset: '../../jest.preset.js' }; 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/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 76e7aae09..8f8d06112 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -43,6 +43,15 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'MSFT': + if (isSameDay(parseDate('2021-09-16'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 331.83 }; + } + + return { marketPrice: 0 }; + case 'NOVN.SW': if (isSameDay(parseDate('2022-04-11'), date)) { return { marketPrice: 87.8 }; 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..fb2f0a389 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 @@ -2,8 +2,10 @@ import { DataSource, Tag } from '@prisma/client'; import Big from 'big.js'; export interface TransactionPointSymbol { + averagePrice: Big; 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-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 000000000..de63bced1 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,118 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; + +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculator } from './portfolio-calculator'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + const portfolioCalculator = new PortfolioCalculator({ + currentRateService, + exchangeRateDataService, + currency: 'USD', + orders: [ + { + currency: 'USD', + date: '2021-09-16', + dataSource: 'YAHOO', + fee: new Big(19), + name: 'Microsoft Inc.', + quantity: new Big(1), + symbol: 'MSFT', + type: 'BUY', + unitPrice: new Big(298.58) + }, + { + currency: 'USD', + date: '2021-11-16', + dataSource: 'YAHOO', + fee: new Big(0), + name: 'Microsoft Inc.', + quantity: new Big(1), + symbol: 'MSFT', + type: 'DIVIDEND', + unitPrice: new Big(0.62) + } + ] + }); + + portfolioCalculator.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + + const currentPositions = await portfolioCalculator.getCurrentPositions( + parseDate('2023-07-10') + ); + + spy.mockRestore(); + + expect(currentPositions).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + fee: new Big('19'), + firstBuyDate: '2021-09-16', + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + quantity: new Big('1'), + symbol: 'MSFT', + tags: undefined, + transactionCount: 2 + } + ], + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58') + }); + }); + }); +}); 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..f0551f3b8 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; @@ -77,33 +78,32 @@ export class PortfolioCalculator { const oldAccumulatedSymbol = symbols[order.symbol]; const factor = getFactor(order.type); - const unitPrice = new Big(order.unitPrice); + if (oldAccumulatedSymbol) { + let investment = oldAccumulatedSymbol.investment; + const newQuantity = order.quantity .mul(factor) .plus(oldAccumulatedSymbol.quantity); - let investment = new Big(0); - - if (newQuantity.gt(0)) { - if (order.type === 'BUY') { - investment = oldAccumulatedSymbol.investment.plus( - order.quantity.mul(unitPrice) - ); - } else if (order.type === 'SELL') { - const averagePrice = oldAccumulatedSymbol.investment.div( - oldAccumulatedSymbol.quantity - ); - investment = oldAccumulatedSymbol.investment.minus( - order.quantity.mul(averagePrice) - ); - } + if (order.type === 'BUY') { + investment = oldAccumulatedSymbol.investment.plus( + order.quantity.mul(order.unitPrice) + ); + } else if (order.type === 'SELL') { + investment = oldAccumulatedSymbol.investment.minus( + order.quantity.mul(oldAccumulatedSymbol.averagePrice) + ); } currentTransactionPointItem = { investment, + averagePrice: newQuantity.gt(0) + ? investment.div(newQuantity) + : new Big(0), currency: order.currency, dataSource: order.dataSource, + dividend: new Big(0), fee: order.fee.plus(oldAccumulatedSymbol.fee), firstBuyDate: oldAccumulatedSymbol.firstBuyDate, quantity: newQuantity, @@ -113,11 +113,13 @@ export class PortfolioCalculator { }; } else { currentTransactionPointItem = { + averagePrice: order.unitPrice, currency: order.currency, dataSource: order.dataSource, + dividend: new Big(0), fee: order.fee, firstBuyDate: order.date, - investment: unitPrice.mul(order.quantity).mul(factor), + investment: order.unitPrice.mul(order.quantity).mul(factor), quantity: order.quantity.mul(factor), symbol: order.symbol, tags: order.tags, @@ -128,22 +130,28 @@ export class PortfolioCalculator { symbols[order.symbol] = currentTransactionPointItem; const items = lastTransactionPoint?.items ?? []; + const newItems = items.filter( (transactionPointItem) => transactionPointItem.symbol !== order.symbol ); + newItems.push(currentTransactionPointItem); + newItems.sort((a, b) => { return a.symbol?.localeCompare(b.symbol); }); + if (lastDate !== currentDate || lastTransactionPoint === null) { lastTransactionPoint = { date: currentDate, items: newItems }; + this.transactionPoints.push(lastTransactionPoint); } else { lastTransactionPoint.items = newItems; } + lastDate = currentDate; } } @@ -583,6 +591,8 @@ export class PortfolioCalculator { ); const { + dividend, + dividendInBaseCurrency, grossPerformance, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, @@ -608,11 +618,11 @@ export class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; positions.push({ + dividend, + dividendInBaseCurrency, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, - averagePrice: item.quantity.eq(0) - ? new Big(0) - : item.investment.div(item.quantity), + averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, fee: item.fee, @@ -842,6 +852,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 +906,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 +948,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 +1124,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 +1202,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 +1300,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 +1494,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 +1519,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..d55b3d647 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,7 @@ export class PortfolioService { averagePrice, currency, dataSource, + dividendInBaseCurrency, fee, firstBuyDate, marketPrice, @@ -780,16 +770,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', @@ -823,9 +803,7 @@ export class PortfolioService { ); if (currentSymbol) { - currentAveragePrice = currentSymbol.quantity.eq(0) - ? 0 - : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + currentAveragePrice = currentSymbol.averagePrice.toNumber(); currentQuantity = currentSymbol.quantity.toNumber(); } @@ -1636,6 +1614,7 @@ export class PortfolioService { countries: [], dataSource: undefined, dateOfFirstActivity: undefined, + dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, @@ -1765,11 +1744,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 +1888,6 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, - dividend, excludedAccountsAndActivities, fees, firstOrderDate, @@ -1915,6 +1898,7 @@ export class PortfolioService { totalBuy, totalSell, committedFunds: committedFunds.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundPositionsValueInBaseCurrency, cash: emergencyFund @@ -1967,7 +1951,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 +2128,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/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index e32af51d3..a25f3a356 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -22,6 +22,13 @@ export const ExchangeRateDataServiceMock = { '2023-07-10': 0.8854 } }); + } else if (targetCurrency === 'USD') { + return Promise.resolve({ + USDUSD: { + '2018-01-01': 1, + '2023-07-10': 1 + } + }); } return Promise.resolve({}); 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; From 7a3237f1ff2fa20887c351921a7eae3f27131bb9 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:59:23 +0100 Subject: [PATCH 3/6] Adapt style of inactive users (#3114) --- .../components/admin-users/admin-users.html | 20 +++++++++++++------ apps/client/src/styles.scss | 4 ++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index 6b83a6ab0..b8eaa28ab 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -35,12 +35,20 @@ mat-cell >
- {{ - element.id - }} - {{ - (element.id | slice: 0 : 5) + '...' - }} + {{ element.id }} + {{ (element.id | slice: 0 : 5) + '...' }} Date: Fri, 8 Mar 2024 19:00:21 +0100 Subject: [PATCH 4/6] Feature/remove environment variable web auth rp (#3115) * Remove environment variable WEB_AUTH_RP_ID * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/auth/web-auth.service.ts | 2 +- .../src/services/configuration/configuration.service.ts | 7 +++---- apps/api/src/services/interfaces/environment.interface.ts | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da3b143b..54764beb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Integrated dividend into the transaction point concept in the portfolio service +- Removed the environment variable `WEB_AUTH_RP_ID` ## 2.61.1 - 2024-03-06 diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index a6e76ffbb..961bbe9a7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -41,7 +41,7 @@ export class WebAuthService { ) {} get rpID() { - return this.configurationService.get('WEB_AUTH_RP_ID'); + return new URL(this.configurationService.get('ROOT_URL')).hostname; } get expectedOrigin() { diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index eb82be418..507e4a375 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; +import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; @Injectable() export class ConfigurationService { @@ -48,14 +48,13 @@ export class ConfigurationService { REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), REQUEST_TIMEOUT: num({ default: 2000 }), - ROOT_URL: str({ default: DEFAULT_ROOT_URL }), + ROOT_URL: url({ default: DEFAULT_ROOT_URL }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), - TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }), - WEB_AUTH_RP_ID: host({ default: 'localhost' }) + TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }) }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 09d0b0e5d..5d3145a28 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -42,5 +42,4 @@ export interface Environment extends CleanedEnvAccessors { TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; TWITTER_API_SECRET: string; - WEB_AUTH_RP_ID: string; } From bc8d8309d49060ca09a61d1657553f5d172d446c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 9 Mar 2024 11:07:01 +0100 Subject: [PATCH 5/6] Improve handling of future liabilities (#3118) * Improve handling of future liabilities * Refactor currentValue to currentValueInBaseCurrency * Update changelog --- CHANGELOG.md | 4 + .../portfolio/current-rate.service.mock.ts | 2 + .../interfaces/current-positions.interface.ts | 4 +- ...folio-calculator-baln-buy-and-sell.spec.ts | 5 +- .../portfolio-calculator-baln-buy.spec.ts | 5 +- ...ator-btcusd-buy-and-sell-partially.spec.ts | 5 +- .../portfolio-calculator-googl-buy.spec.ts | 5 +- .../portfolio-calculator-no-orders.spec.ts | 2 +- ...ulator-novn-buy-and-sell-partially.spec.ts | 5 +- ...folio-calculator-novn-buy-and-sell.spec.ts | 5 +- .../src/app/portfolio/portfolio-calculator.ts | 87 ++++++++++--------- .../src/app/portfolio/portfolio.service.ts | 25 +++--- .../exchange-rate-data.service.mock.ts | 1 + .../exchange-rate-data.service.ts | 22 ++++- .../interfaces/timeline-position.interface.ts | 1 + 15 files changed, 108 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54764beb0..531e0de91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Integrated dividend into the transaction point concept in the portfolio service - Removed the environment variable `WEB_AUTH_RP_ID` +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities + ## 2.61.1 - 2024-03-06 ### Fixed diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 8f8d06112..ed9229691 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -46,6 +46,8 @@ function mockGetValue(symbol: string, date: Date) { case 'MSFT': if (isSameDay(parseDate('2021-09-16'), date)) { return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2021-11-16'), date)) { + return { marketPrice: 339.51 }; } else if (isSameDay(parseDate('2023-07-10'), date)) { return { marketPrice: 331.83 }; } diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts index 5807d6b5e..9ad9ee822 100644 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts @@ -3,7 +3,7 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; import Big from 'big.js'; export interface CurrentPositions extends ResponseError { - positions: TimelinePosition[]; + currentValueInBaseCurrency: Big; grossPerformance: Big; grossPerformanceWithCurrencyEffect: Big; grossPerformancePercentage: Big; @@ -14,6 +14,6 @@ export interface CurrentPositions extends ResponseError { netPerformanceWithCurrencyEffect: Big; netPerformancePercentage: Big; netPerformancePercentageWithCurrencyEffect: Big; - currentValue: Big; + positions: TimelinePosition[]; totalInvestment: 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 9831e5b00..32320dce6 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 @@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('0'), + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), @@ -131,7 +131,8 @@ describe('PortfolioCalculator', () => { symbol: 'BALN.SW', timeWeightedInvestment: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], totalInvestment: new Big('0'), 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 bb5986059..f0aae5536 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 @@ -76,7 +76,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('297.8'), + currentValueInBaseCurrency: new Big('297.8'), errors: [], grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), @@ -120,7 +120,8 @@ describe('PortfolioCalculator', () => { symbol: 'BALN.SW', timeWeightedInvestment: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('297.8') } ], totalInvestment: new Big('273.2'), 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 6126311c3..d0a8aafc0 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 @@ -100,7 +100,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('13298.425356'), + currentValueInBaseCurrency: new Big('13298.425356'), errors: [], grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), @@ -151,7 +151,8 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '636.79469348020066587024' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('13298.425356') } ], totalInvestment: new Big('320.43'), 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 d29498292..c3d2eabff 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 @@ -89,7 +89,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('103.10483'), + currentValueInBaseCurrency: new Big('103.10483'), errors: [], grossPerformance: new Big('27.33'), grossPerformancePercentage: new Big('0.3066651705565529623'), @@ -134,7 +134,8 @@ describe('PortfolioCalculator', () => { tags: undefined, timeWeightedInvestment: new Big('89.12'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') } ], totalInvestment: new Big('89.12'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts index ab7234822..f87075c1f 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts @@ -64,7 +64,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big(0), + currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), 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 afddc5423..8aa5cf0cb 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 @@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('87.8'), + currentValueInBaseCurrency: new Big('87.8'), errors: [], grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), @@ -133,7 +133,8 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '145.10285714285714285714' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('87.8') } ], totalInvestment: new Big('75.80'), 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 4b7750a63..669013858 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 @@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => { }); expect(currentPositions).toEqual({ - currentValue: new Big('0'), + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), @@ -157,7 +157,8 @@ describe('PortfolioCalculator', () => { symbol: 'NOVN.SW', timeWeightedInvestment: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], totalInvestment: new Big('0'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index f0551f3b8..07016efcf 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -22,6 +22,7 @@ import { format, isBefore, isSameDay, + max, subDays } from 'date-fns'; import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash'; @@ -449,16 +450,27 @@ export class PortfolioCalculator { public async getCurrentPositions( start: Date, - end = new Date(Date.now()) + end?: Date ): Promise { - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; + const lastTransactionPoint = last(this.transactionPoints); + + let endDate = end; + + if (!endDate) { + endDate = new Date(Date.now()); - if (!transactionPointsBeforeEndDate.length) { + if (lastTransactionPoint) { + endDate = max([endDate, parseDate(lastTransactionPoint.date)]); + } + } + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), endDate); + }); + + if (!transactionPoints.length) { return { - currentValue: new Big(0), + currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -473,41 +485,40 @@ export class PortfolioCalculator { }; } - const lastTransactionPoint = - transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; - const currencies: { [symbol: string]: string } = {}; const dataGatheringItems: IDataGatheringItem[] = []; let dates: Date[] = []; - let firstIndex = transactionPointsBeforeEndDate.length; + let firstIndex = transactionPoints.length; let firstTransactionPoint: TransactionPoint = null; dates.push(resetHours(start)); - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { + + for (const { currency, dataSource, symbol } of transactionPoints[ + firstIndex - 1 + ].items) { dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol + dataSource, + symbol }); - currencies[item.symbol] = item.currency; + currencies[symbol] = currency; } - for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { + for (let i = 0; i < transactionPoints.length; i++) { if ( - !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && + !isBefore(parseDate(transactionPoints[i].date), start) && firstTransactionPoint === null ) { - firstTransactionPoint = transactionPointsBeforeEndDate[i]; + firstTransactionPoint = transactionPoints[i]; firstIndex = i; } + if (firstTransactionPoint !== null) { - dates.push( - resetHours(parseDate(transactionPointsBeforeEndDate[i].date)) - ); + dates.push(resetHours(parseDate(transactionPoints[i].date))); } } - dates.push(resetHours(end)); + dates.push(resetHours(endDate)); // Add dates of last week for fallback dates.push(subDays(resetHours(new Date()), 7)); @@ -534,7 +545,7 @@ export class PortfolioCalculator { let exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), + endDate: endOfDay(endDate), startDate: parseDate(this.transactionPoints?.[0]?.date), targetCurrency: this.currency }); @@ -570,7 +581,7 @@ export class PortfolioCalculator { } } - const endDateString = format(end, DATE_FORMAT); + const endDateString = format(endDate, DATE_FORMAT); if (firstIndex > 0) { firstIndex--; @@ -582,9 +593,9 @@ export class PortfolioCalculator { const errors: ResponseError['errors'] = []; for (const item of lastTransactionPoint.items) { - const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ - item.symbol - ]?.mul( + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ endDateString ] @@ -607,9 +618,9 @@ export class PortfolioCalculator { totalInvestment, totalInvestmentWithCurrencyEffect } = this.getSymbolMetrics({ - end, marketSymbolMap, start, + end: endDate, exchangeRates: exchangeRatesByCurrency[`${item.currency}${this.currency}`], symbol: item.symbol @@ -656,7 +667,10 @@ export class PortfolioCalculator { quantity: item.quantity, symbol: item.symbol, tags: item.tags, - transactionCount: item.transactionCount + transactionCount: item.transactionCount, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) }); if ( @@ -725,7 +739,7 @@ export class PortfolioCalculator { } private calculateOverallPerformance(positions: TimelinePosition[]) { - let currentValue = new Big(0); + let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; @@ -737,14 +751,9 @@ export class PortfolioCalculator { let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { - if ( - currentPosition.investment && - currentPosition.marketPriceInBaseCurrency - ) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPriceInBaseCurrency).mul( - currentPosition.quantity - ) + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency ); } else { hasErrors = true; @@ -801,7 +810,7 @@ export class PortfolioCalculator { } return { - currentValue, + currentValueInBaseCurrency, grossPerformance, grossPerformanceWithCurrencyEffect, hasErrors, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index d55b3d647..78a803e9e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -378,9 +378,10 @@ export class PortfolioService { }); const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = currentPositions.currentValue.plus( - cashDetails.balanceInBaseCurrency - ); + const totalValueInBaseCurrency = + currentPositions.currentValueInBaseCurrency.plus( + cashDetails.balanceInBaseCurrency + ); const isFilteredByAccount = filters?.some((filter) => { @@ -389,7 +390,7 @@ export class PortfolioService { let filteredValueInBaseCurrency = isFilteredByAccount ? totalValueInBaseCurrency - : currentPositions.currentValue; + : currentPositions.currentValueInBaseCurrency; if ( filters?.length === 0 || @@ -444,14 +445,14 @@ export class PortfolioService { quantity, symbol, tags, - transactionCount + transactionCount, + valueInBaseCurrency } of currentPositions.positions) { if (quantity.eq(0)) { // Ignore positions without any quantity continue; } - const value = quantity.mul(marketPriceInBaseCurrency ?? 0); const symbolProfile = symbolProfileMap[symbol]; const dataProviderResponse = dataProviderResponses[symbol]; @@ -517,11 +518,11 @@ export class PortfolioService { } } else { markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) - .plus(value) + .plus(valueInBaseCurrency) .toNumber(); marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) - .plus(value) + .plus(valueInBaseCurrency) .toNumber(); } @@ -535,7 +536,7 @@ export class PortfolioService { transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 - : value.div(filteredValueInBaseCurrency).toNumber(), + : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), assetClass: symbolProfile.assetClass, assetSubClass: symbolProfile.assetSubClass, countries: symbolProfile.countries, @@ -560,7 +561,7 @@ export class PortfolioService { quantity: quantity.toNumber(), sectors: symbolProfile.sectors, url: symbolProfile.url, - valueInBaseCurrency: value.toNumber() + valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -1175,7 +1176,7 @@ export class PortfolioService { const startDate = this.getStartDate(dateRange, portfolioStart); const { - currentValue, + currentValueInBaseCurrency, errors, grossPerformance, grossPerformancePercentage, @@ -1270,7 +1271,7 @@ export class PortfolioService { currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect: currentNetPerformanceWithCurrencyEffect.toNumber(), - currentValue: currentValue.toNumber(), + currentValue: currentValueInBaseCurrency.toNumber(), totalInvestment: totalInvestment.toNumber() } }; diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index a25f3a356..59f5144d8 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -26,6 +26,7 @@ export const ExchangeRateDataServiceMock = { return Promise.resolve({ USDUSD: { '2018-01-01': 1, + '2021-11-16': 1, '2023-07-10': 1 } }); diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 148fac560..a02ddb597 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -73,7 +73,17 @@ export class ExchangeRateDataService { currencyTo: targetCurrency }); - let previousExchangeRate = 1; + const dateStrings = Object.keys( + exchangeRatesByCurrency[`${currency}${targetCurrency}`] + ); + const lastDateString = dateStrings.reduce((a, b) => { + return a > b ? a : b; + }); + + let previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ + lastDateString + ] ?? 1; // Start from the most recent date and fill in missing exchange rates // using the latest available rate @@ -94,7 +104,7 @@ export class ExchangeRateDataService { exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = previousExchangeRate; - if (currency === DEFAULT_CURRENCY) { + if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { Logger.error( `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, 'ExchangeRateDataService' @@ -433,13 +443,17 @@ export class ExchangeRateDataService { ]) * marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; - factors[format(date, DATE_FORMAT)] = factor; + if (isNaN(factor)) { + throw new Error('Exchange rate is not a number'); + } else { + factors[format(date, DATE_FORMAT)] = factor; + } } catch { Logger.error( `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( date, DATE_FORMAT - )}`, + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`, 'ExchangeRateDataService' ); } diff --git a/libs/common/src/lib/interfaces/timeline-position.interface.ts b/libs/common/src/lib/interfaces/timeline-position.interface.ts index 831c29b31..8e5dbb8e8 100644 --- a/libs/common/src/lib/interfaces/timeline-position.interface.ts +++ b/libs/common/src/lib/interfaces/timeline-position.interface.ts @@ -27,4 +27,5 @@ export interface TimelinePosition { timeWeightedInvestment: Big; timeWeightedInvestmentWithCurrencyEffect: Big; transactionCount: number; + valueInBaseCurrency: Big; } From b642ce08e573e3c11724ef39a255ecc4d496d32d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 9 Mar 2024 12:32:56 +0100 Subject: [PATCH 6/6] Refactor item type (#3119) --- .../portfolio-calculator.interface.ts | 2 +- .../src/app/portfolio/portfolio-calculator.ts | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts index 357b454fd..c5266fb33 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts @@ -5,7 +5,7 @@ import { PortfolioOrder } from './portfolio-order.interface'; export interface PortfolioOrderItem extends PortfolioOrder { feeInBaseCurrency?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big; - itemType?: '' | 'start' | 'end'; + itemType?: 'end' | 'start'; unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 07016efcf..1521787e7 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1049,28 +1049,26 @@ export class PortfolioCalculator { } } - // Sort orders so that the start and end placeholder order are at the right + // Sort orders so that the start and end placeholder order are at the correct // position - orders = sortBy(orders, (order) => { - let sortIndex = new Date(order.date); + orders = sortBy(orders, ({ date, itemType }) => { + let sortIndex = new Date(date); - if (order.itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); - } - - if (order.itemType === 'end') { + if (itemType === 'end') { sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); } return sortIndex.getTime(); }); - const indexOfStartOrder = orders.findIndex((order) => { - return order.itemType === 'start'; + const indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; }); - const indexOfEndOrder = orders.findIndex((order) => { - return order.itemType === 'end'; + const indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; }); let totalInvestmentDays = 0;