diff --git a/CHANGELOG.md b/CHANGELOG.md index bae78f3b9..76f617538 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 - Extended the export functionality by the user account’s currency - Added support to override the name of an asset profile in the asset profile details dialog of the admin control +### Changed + +- Optimized the portfolio calculations + ### Fixed - Fixed the chart tooltip of the benchmark comparator diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 558a4643d..63a936c32 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,15 +1,12 @@ -import { DataSource, Tag, Type as ActivityType } from '@prisma/client'; -import { Big } from 'big.js'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -export interface PortfolioOrder { - currency: string; +export interface PortfolioOrder extends Pick { date: string; - dataSource: DataSource; fee: Big; - name: string; quantity: Big; - symbol: string; - tags?: Tag[]; - type: ActivityType; + SymbolProfile: Pick< + Activity['SymbolProfile'], + 'currency' | 'dataSource' | 'name' | 'symbol' + >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 4db8dcdb7..4a90409c9 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -1,3 +1,4 @@ +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 { parseDate } from '@ghostfolio/common/helper'; @@ -36,46 +37,50 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'CHF', - date: '2021-11-22', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'BUY', - unitPrice: new Big(142.9) + unitPrice: 142.9 }, { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(1.65), - name: 'Bâloise Holding AG', - quantity: new Big(1), - symbol: 'BALN.SW', + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 1, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'SELL', - unitPrice: new Big(136.6) + unitPrice: 136.6 }, { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Bâloise Holding AG', - quantity: new Big(1), - symbol: 'BALN.SW', + date: new Date('2021-11-30'), + fee: 0, + quantity: 1, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'SELL', - unitPrice: new Big(136.6) + unitPrice: 136.6 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); 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 b99f93252..6176acbb5 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 @@ -1,3 +1,4 @@ +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 { parseDate } from '@ghostfolio/common/helper'; @@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'CHF', - date: '2021-11-22', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'BUY', - unitPrice: new Big(142.9) + unitPrice: 142.9 }, { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(1.65), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'SELL', - unitPrice: new Big(136.6) + unitPrice: 136.6 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); 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 15208aca5..61dfb5915 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 @@ -1,3 +1,4 @@ +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 { parseDate } from '@ghostfolio/common/helper'; @@ -36,24 +37,24 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', + date: new Date('2021-11-30'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, type: 'BUY', - unitPrice: new Big(136.6) + unitPrice: 136.6 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); 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 e9d88721d..d17a12ba8 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 @@ -1,3 +1,4 @@ +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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; @@ -49,35 +50,37 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'USD', - date: '2015-01-01', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Bitcoin USD', - quantity: new Big(2), - symbol: 'BTCUSD', + date: new Date('2015-01-01'), + fee: 0, + quantity: 2, + SymbolProfile: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, type: 'BUY', - unitPrice: new Big(320.43) + unitPrice: 320.43 }, { - currency: 'USD', - date: '2017-12-31', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Bitcoin USD', - quantity: new Big(1), - symbol: 'BTCUSD', + date: new Date('2017-12-31'), + fee: 0, + quantity: 1, + SymbolProfile: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, type: 'SELL', - unitPrice: new Big(14156.4) + unitPrice: 14156.4 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2018-01-01').getTime()); 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 1a672766d..5e870b447 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 @@ -1,3 +1,4 @@ +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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; @@ -49,24 +50,24 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'USD', - date: '2023-01-03', - dataSource: 'YAHOO', - fee: new Big(1), - name: 'Alphabet Inc.', - quantity: new Big(1), - symbol: 'GOOGL', + date: new Date('2023-01-03'), + fee: 1, + quantity: 1, + SymbolProfile: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, type: 'BUY', - unitPrice: new Big(89.12) + unitPrice: 89.12 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2023-07-10').getTime()); 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 index 8920a6cba..9ec51094e 100644 --- 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 @@ -1,3 +1,4 @@ +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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; @@ -49,35 +50,37 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'USD', - orders: [ + activities: [ { - currency: 'USD', - date: '2021-09-16', - dataSource: 'YAHOO', - fee: new Big(19), - name: 'Microsoft Inc.', - quantity: new Big(1), - symbol: 'MSFT', + date: new Date('2021-09-16'), + fee: 19, + quantity: 1, + SymbolProfile: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, type: 'BUY', - unitPrice: new Big(298.58) + unitPrice: 298.58 }, { - currency: 'USD', - date: '2021-11-16', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Microsoft Inc.', - quantity: new Big(1), - symbol: 'MSFT', + date: new Date('2021-11-16'), + fee: 0, + quantity: 1, + SymbolProfile: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, type: 'DIVIDEND', - unitPrice: new Big(0.62) + unitPrice: 0.62 } - ] + ], + currency: 'USD' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2023-07-10').getTime()); 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 fdbdb78bd..9947e2f45 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 @@ -37,12 +37,10 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [] + activities: [], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); 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 d71a97072..8eeb216ba 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 @@ -1,3 +1,4 @@ +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 { parseDate } from '@ghostfolio/common/helper'; @@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'CHF', - date: '2022-03-07', - dataSource: 'YAHOO', - fee: new Big(1.3), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', + date: new Date('2022-03-07'), + fee: 1.3, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, type: 'BUY', - unitPrice: new Big(75.8) + unitPrice: 75.8 }, { - currency: 'CHF', - date: '2022-04-08', - dataSource: 'YAHOO', - fee: new Big(2.95), - name: 'Novartis AG', - quantity: new Big(1), - symbol: 'NOVN.SW', + date: new Date('2022-04-08'), + fee: 2.95, + quantity: 1, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, type: 'SELL', - unitPrice: new Big(85.73) + unitPrice: 85.73 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2022-04-11').getTime()); 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 a27b6d42a..ec00fffe7 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 @@ -1,3 +1,4 @@ +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 { parseDate } from '@ghostfolio/common/helper'; @@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, exchangeRateDataService, - currency: 'CHF', - orders: [ + activities: [ { - currency: 'CHF', - date: '2022-03-07', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', + date: new Date('2022-03-07'), + fee: 0, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, type: 'BUY', - unitPrice: new Big(75.8) + unitPrice: 75.8 }, { - currency: 'CHF', - date: '2022-04-08', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', + date: new Date('2022-04-08'), + fee: 0, + quantity: 2, + SymbolProfile: { + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, type: 'SELL', - unitPrice: new Big(85.73) + unitPrice: 85.73 } - ] + ], + currency: 'CHF' }); - portfolioCalculator.computeTransactionPoints(); - const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2022-04-11').getTime()); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts index b8d784dc3..a385774ff 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts @@ -22,10 +22,10 @@ describe('PortfolioCalculator', () => { describe('annualized performance percentage', () => { const portfolioCalculator = new PortfolioCalculator({ + activities: [], currentRateService, exchangeRateDataService, - currency: 'USD', - orders: [] + currency: 'USD' }); it('Get annualized performance', async () => { diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index d37a872c5..d95acc836 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1,3 +1,4 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; @@ -8,7 +9,8 @@ import { InvestmentItem, ResponseError, SymbolMetrics, - TimelinePosition + TimelinePosition, + UniqueAsset } from '@ghostfolio/common/interfaces'; import { GroupBy } from '@ghostfolio/common/types'; @@ -46,122 +48,37 @@ export class PortfolioCalculator { private transactionPoints: TransactionPoint[]; public constructor({ + activities, currency, currentRateService, - exchangeRateDataService, - orders, - transactionPoints + exchangeRateDataService }: { + activities: Activity[]; currency: string; currentRateService: CurrentRateService; exchangeRateDataService: ExchangeRateDataService; - orders: PortfolioOrder[]; - transactionPoints?: TransactionPoint[]; }) { this.currency = currency; this.currentRateService = currentRateService; this.exchangeRateDataService = exchangeRateDataService; - this.orders = orders; + this.orders = activities.map( + ({ date, fee, quantity, SymbolProfile, type, unitPrice }) => { + return { + SymbolProfile, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ); this.orders.sort((a, b) => { return a.date?.localeCompare(b.date); }); - if (transactionPoints) { - this.transactionPoints = transactionPoints; - } - } - - public computeTransactionPoints() { - this.transactionPoints = []; - const symbols: { [symbol: string]: TransactionPointSymbol } = {}; - - let lastDate: string = null; - let lastTransactionPoint: TransactionPoint = null; - - for (const order of this.orders) { - const currentDate = order.date; - - let currentTransactionPointItem: TransactionPointSymbol; - const oldAccumulatedSymbol = symbols[order.symbol]; - - const factor = getFactor(order.type); - - if (oldAccumulatedSymbol) { - let investment = oldAccumulatedSymbol.investment; - - const newQuantity = order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity); - - 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, - symbol: order.symbol, - tags: order.tags, - transactionCount: oldAccumulatedSymbol.transactionCount + 1 - }; - } else { - currentTransactionPointItem = { - averagePrice: order.unitPrice, - currency: order.currency, - dataSource: order.dataSource, - dividend: new Big(0), - fee: order.fee, - firstBuyDate: order.date, - investment: order.unitPrice.mul(order.quantity).mul(factor), - quantity: order.quantity.mul(factor), - symbol: order.symbol, - tags: order.tags, - transactionCount: 1 - }; - } - - 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; - } + this.computeTransactionPoints(); } public getAnnualizedPerformancePercent({ @@ -181,10 +98,6 @@ export class PortfolioCalculator { return new Big(0); } - public getTransactionPoints(): TransactionPoint[] { - return this.transactionPoints; - } - public async getChartData({ end = new Date(Date.now()), start, @@ -248,7 +161,7 @@ export class PortfolioCalculator { await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: uniq(Object.values(currencies)), endDate: endOfDay(end), - startDate: parseDate(this.transactionPoints?.[0]?.date), + startDate: this.getStartDate(), targetCurrency: this.currency }); @@ -309,6 +222,7 @@ export class PortfolioCalculator { start, step, symbol, + dataSource: null, exchangeRates: exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], isChartMode: true @@ -551,7 +465,7 @@ export class PortfolioCalculator { await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: uniq(Object.values(currencies)), endDate: endOfDay(endDate), - startDate: parseDate(this.transactionPoints?.[0]?.date), + startDate: this.getStartDate(), targetCurrency: this.currency }); @@ -625,6 +539,7 @@ export class PortfolioCalculator { } = this.getSymbolMetrics({ marketSymbolMap, start, + dataSource: item.dataSource, end: endDate, exchangeRates: exchangeRatesByCurrency[`${item.currency}${this.currency}`], @@ -844,7 +759,116 @@ export class PortfolioCalculator { }; } + public getStartDate() { + return this.transactionPoints.length > 0 + ? parseDate(this.transactionPoints[0].date) + : new Date(); + } + + public getTransactionPoints() { + return this.transactionPoints; + } + + private computeTransactionPoints() { + this.transactionPoints = []; + const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + + let lastDate: string = null; + let lastTransactionPoint: TransactionPoint = null; + + for (const { + fee, + date, + quantity, + SymbolProfile, + tags, + type, + unitPrice + } of this.orders) { + let currentTransactionPointItem: TransactionPointSymbol; + const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; + + const factor = getFactor(type); + + if (oldAccumulatedSymbol) { + let investment = oldAccumulatedSymbol.investment; + + const newQuantity = quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + + if (type === 'BUY') { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(unitPrice) + ); + } else if (type === 'SELL') { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } + + currentTransactionPointItem = { + investment, + tags, + averagePrice: newQuantity.gt(0) + ? investment.div(newQuantity) + : new Big(0), + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + dividend: new Big(0), + fee: fee.plus(oldAccumulatedSymbol.fee), + firstBuyDate: oldAccumulatedSymbol.firstBuyDate, + quantity: newQuantity, + symbol: SymbolProfile.symbol, + transactionCount: oldAccumulatedSymbol.transactionCount + 1 + }; + } else { + currentTransactionPointItem = { + fee, + tags, + averagePrice: unitPrice, + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + dividend: new Big(0), + firstBuyDate: date, + investment: unitPrice.mul(quantity).mul(factor), + quantity: quantity.mul(factor), + symbol: SymbolProfile.symbol, + transactionCount: 1 + }; + } + + symbols[SymbolProfile.symbol] = currentTransactionPointItem; + + const items = lastTransactionPoint?.items ?? []; + + const newItems = items.filter(({ symbol }) => { + return symbol !== SymbolProfile.symbol; + }); + + newItems.push(currentTransactionPointItem); + + newItems.sort((a, b) => { + return a.symbol?.localeCompare(b.symbol); + }); + + if (lastDate !== date || lastTransactionPoint === null) { + lastTransactionPoint = { + date, + items: newItems + }; + + this.transactionPoints.push(lastTransactionPoint); + } else { + lastTransactionPoint.items = newItems; + } + + lastDate = date; + } + } + private getSymbolMetrics({ + dataSource, end, exchangeRates, isChartMode = false, @@ -861,8 +885,7 @@ export class PortfolioCalculator { }; start: Date; step?: number; - symbol: string; - }): SymbolMetrics { + } & UniqueAsset): SymbolMetrics { const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentValues: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; @@ -908,8 +931,8 @@ export class PortfolioCalculator { // Clone orders to keep the original values in this.orders let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( - (order) => { - return order.symbol === symbol; + ({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; } ); @@ -988,28 +1011,28 @@ export class PortfolioCalculator { // Add a synthetic order at the start and the end date orders.push({ - symbol, - currency: null, date: format(start, DATE_FORMAT), - dataSource: null, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'start', - name: '', quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, type: 'BUY', unitPrice: unitPriceAtStartDate }); orders.push({ - symbol, - currency: null, date: format(end, DATE_FORMAT), - dataSource: null, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'end', - name: '', + SymbolProfile: { + dataSource, + symbol + }, quantity: new Big(0), type: 'BUY', unitPrice: unitPriceAtEndDate @@ -1030,14 +1053,14 @@ export class PortfolioCalculator { if (!hasDate) { orders.push({ - symbol, - currency: null, date: format(day, DATE_FORMAT), - dataSource: null, fee: new Big(0), feeInBaseCurrency: new Big(0), - name: '', quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, type: 'BUY', unitPrice: marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 061c4b8be..fe0e5a601 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -4,8 +4,6 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; -import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; @@ -266,15 +264,15 @@ export class PortfolioService { }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - includeDrafts: true, - types: ['BUY', 'SELL'] - }); + const { activities } = await this.orderService.getOrders({ + filters, + userId, + includeDrafts: true, + types: ['BUY', 'SELL'], + userCurrency: this.getUserCurrency() + }); - if (transactionPoints.length === 0) { + if (activities.length === 0) { return { investments: [], streaks: { currentStreak: 0, longestStreak: 0 } @@ -282,18 +280,16 @@ export class PortfolioService { } const portfolioCalculator = new PortfolioCalculator({ - transactionPoints, + activities, currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); const { items } = await this.getChart({ dateRange, impersonationId, portfolioCalculator, - transactionPoints, userId, withDataDecimation: false }); @@ -364,26 +360,25 @@ export class PortfolioService { }); } - const { activities, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - types, - userId, - withExcludedAccounts - }); + const { activities } = await this.orderService.getOrders({ + filters, + types, + userCurrency, + userId, + withExcludedAccounts + }); const portfolioCalculator = new PortfolioCalculator({ - transactionPoints, + activities, currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) + const startDate = this.getStartDate( + dateRange, + portfolioCalculator.getStartDate() ); - const startDate = this.getStartDate(dateRange, portfolioStart); const currentPositions = await portfolioCalculator.getCurrentPositions(startDate); @@ -737,39 +732,22 @@ export class PortfolioService { { dataSource: aDataSource, symbol: aSymbol } ]); - const portfolioOrders: PortfolioOrder[] = orders - .filter((order) => { - tags = tags.concat(order.tags); - - return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); - }) - .map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - tags = uniqBy(tags, 'id'); const portfolioCalculator = new PortfolioCalculator({ + activities: orders.filter((order) => { + tags = tags.concat(order.tags); + + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); + }), currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); - portfolioCalculator.computeTransactionPoints(); + const portfolioStart = portfolioCalculator.getStartDate(); const transactionPoints = portfolioCalculator.getTransactionPoints(); - const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = await portfolioCalculator.getCurrentPositions(portfolioStart); @@ -982,14 +960,14 @@ export class PortfolioService { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - types: ['BUY', 'SELL'] - }); + const { activities } = await this.orderService.getOrders({ + filters, + userId, + types: ['BUY', 'SELL'], + userCurrency: this.getUserCurrency() + }); - if (transactionPoints?.length <= 0) { + if (activities?.length <= 0) { return { hasErrors: false, positions: [] @@ -997,15 +975,16 @@ export class PortfolioService { } const portfolioCalculator = new PortfolioCalculator({ - transactionPoints, + activities, currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); + const startDate = this.getStartDate( + dateRange, + portfolioCalculator.getStartDate() + ); const currentPositions = await portfolioCalculator.getCurrentPositions(startDate); @@ -1154,15 +1133,15 @@ export class PortfolioService { ) ); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts, - types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL'] - }); + const { activities } = await this.orderService.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts, + types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL'] + }); - if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { + if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { return { chart: [], firstOrderDate: undefined, @@ -1184,17 +1163,16 @@ export class PortfolioService { } const portfolioCalculator = new PortfolioCalculator({ - transactionPoints, + activities, currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); const portfolioStart = min( [ parseDate(accountBalanceItems[0]?.date), - parseDate(transactionPoints[0]?.date) + portfolioCalculator.getStartDate() ].filter((date) => { return isValid(date); }) @@ -1230,7 +1208,6 @@ export class PortfolioService { dateRange, impersonationId, portfolioCalculator, - transactionPoints, userId }); @@ -1307,25 +1284,22 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const { activities, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - userId, - types: ['BUY', 'SELL'] - }); + const { activities } = await this.orderService.getOrders({ + userCurrency, + userId, + types: ['BUY', 'SELL'] + }); const portfolioCalculator = new PortfolioCalculator({ - transactionPoints, + activities, currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + exchangeRateDataService: this.exchangeRateDataService }); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioCalculator.getStartDate() ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); const positions = currentPositions.positions.filter( (item) => !item.quantity.eq(0) @@ -1455,18 +1429,16 @@ export class PortfolioService { dateRange = 'max', impersonationId, portfolioCalculator, - transactionPoints, userId, withDataDecimation = true }: { dateRange?: DateRange; impersonationId: string; portfolioCalculator: PortfolioCalculator; - transactionPoints: TransactionPoint[]; userId: string; withDataDecimation?: boolean; }): Promise { - if (transactionPoints.length === 0) { + if (portfolioCalculator.getTransactionPoints().length === 0) { return { isAllTimeHigh: false, isAllTimeLow: false, @@ -1476,8 +1448,10 @@ export class PortfolioService { userId = await this.getUserId(impersonationId, userId); - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); + const startDate = this.getStartDate( + dateRange, + portfolioCalculator.getStartDate() + ); const endDate = new Date(); const daysInMarket = differenceInDays(endDate, startDate) + 1; const step = withDataDecimation @@ -1865,10 +1839,10 @@ export class PortfolioService { const daysInMarket = differenceInDays(new Date(), firstOrderDate); const annualizedPerformancePercent = new PortfolioCalculator({ + activities: [], currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] + exchangeRateDataService: this.exchangeRateDataService }) .getAnnualizedPerformancePercent({ daysInMarket, @@ -1880,10 +1854,10 @@ export class PortfolioService { const annualizedPerformancePercentWithCurrencyEffect = new PortfolioCalculator({ + activities: [], currency: userCurrency, currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] + exchangeRateDataService: this.exchangeRateDataService }) .getAnnualizedPerformancePercent({ daysInMarket, @@ -1955,71 +1929,9 @@ export class PortfolioService { ); } - private async getTransactionPoints({ - filters, - includeDrafts = false, - types = getAllActivityTypes(), - userId, - withExcludedAccounts = false - }: { - filters?: Filter[]; - includeDrafts?: boolean; - types?: ActivityType[]; - userId: string; - withExcludedAccounts?: boolean; - }): Promise<{ - activities: Activity[]; - transactionPoints: TransactionPoint[]; - portfolioOrders: PortfolioOrder[]; - }> { - const userCurrency = - this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; - - const { activities, count } = await this.orderService.getOrders({ - filters, - includeDrafts, - types, - userCurrency, - userId, - withExcludedAccounts - }); - - if (count <= 0) { - return { activities: [], transactionPoints: [], portfolioOrders: [] }; - } - - const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - portfolioCalculator.computeTransactionPoints(); - - return { - activities, - portfolioOrders, - transactionPoints: portfolioCalculator.getTransactionPoints() - }; - } - - private getUserCurrency(aUser: UserWithSettings) { + private getUserCurrency(aUser?: UserWithSettings) { return ( - aUser.Settings?.settings.baseCurrency ?? + aUser?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY );