From f15b33e9502c3ca5ec5fd7f85b360b64f9264812 Mon Sep 17 00:00:00 2001 From: gizmodus Date: Mon, 24 Jan 2022 20:35:13 +0100 Subject: [PATCH] Portfolio calculator rework (#632) * Portfolio calculator rework Co-authored-by: Reto Kaul --- .../api/src/app/account/account.controller.ts | 11 +- .../portfolio-calculator.interface.ts | 5 + .../app/portfolio/portfolio-calculator-new.ts | 897 +++++++++++++ .../portfolio/portfolio-service.factory.ts | 19 + .../src/app/portfolio/portfolio.controller.ts | 62 +- .../api/src/app/portfolio/portfolio.module.ts | 6 +- .../app/portfolio/portfolio.service-new.ts | 1190 +++++++++++++++++ 7 files changed, 2150 insertions(+), 40 deletions(-) create mode 100644 apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new.ts create mode 100644 apps/api/src/app/portfolio/portfolio-service.factory.ts create mode 100644 apps/api/src/app/portfolio/portfolio.service-new.ts diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 54fd912fd..e0049a904 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,4 +1,4 @@ -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { PortfolioServiceFactory } from '@ghostfolio/api/app/portfolio/portfolio-service.factory'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { nullifyValuesInObject, @@ -35,7 +35,7 @@ export class AccountController { public constructor( private readonly accountService: AccountService, private readonly impersonationService: ImpersonationService, - private readonly portfolioService: PortfolioService, + private readonly portfolioServiceFactory: PortfolioServiceFactory, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -91,10 +91,9 @@ export class AccountController { this.request.user.id ); - let accountsWithAggregations = - await this.portfolioService.getAccountsWithAggregations( - impersonationUserId || this.request.user.id - ); + let accountsWithAggregations = await this.portfolioServiceFactory + .get() + .getAccountsWithAggregations(impersonationUserId || this.request.user.id); if ( impersonationUserId || diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts new file mode 100644 index 000000000..88026cdc7 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts @@ -0,0 +1,5 @@ +import { PortfolioOrder } from './portfolio-order.interface'; + +export interface PortfolioOrderItem extends PortfolioOrder { + itemType?: '' | 'start' | 'end'; +} diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new.ts b/apps/api/src/app/portfolio/portfolio-calculator-new.ts new file mode 100644 index 000000000..7f102795f --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-new.ts @@ -0,0 +1,897 @@ +import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; +import { TimelinePosition } from '@ghostfolio/common/interfaces'; +import { Logger } from '@nestjs/common'; +import { Type as TypeOfOrder } from '@prisma/client'; +import Big from 'big.js'; +import { + addDays, + addMilliseconds, + addMonths, + addYears, + differenceInDays, + endOfDay, + format, + isAfter, + isBefore, + max, + min +} from 'date-fns'; +import { first, flatten, isNumber, sortBy } from 'lodash'; + +import { CurrentRateService } from './current-rate.service'; +import { CurrentPositions } from './interfaces/current-positions.interface'; +import { GetValueObject } from './interfaces/get-value-object.interface'; +import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface'; +import { PortfolioOrder } from './interfaces/portfolio-order.interface'; +import { TimelinePeriod } from './interfaces/timeline-period.interface'; +import { + Accuracy, + TimelineSpecification +} from './interfaces/timeline-specification.interface'; +import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface'; +import { TransactionPoint } from './interfaces/transaction-point.interface'; + +export class PortfolioCalculatorNew { + private currency: string; + private currentRateService: CurrentRateService; + private orders: PortfolioOrder[]; + private transactionPoints: TransactionPoint[]; + + public constructor({ + currency, + currentRateService, + orders + }: { + currency: string; + currentRateService: CurrentRateService; + orders: PortfolioOrder[]; + }) { + this.currency = currency; + this.currentRateService = currentRateService; + this.orders = orders; + + this.orders.sort((a, b) => a.date.localeCompare(b.date)); + } + + 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 = this.getFactor(order.type); + const unitPrice = new Big(order.unitPrice); + if (oldAccumulatedSymbol) { + const newQuantity = order.quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + currentTransactionPointItem = { + currency: order.currency, + dataSource: order.dataSource, + fee: order.fee.plus(oldAccumulatedSymbol.fee), + firstBuyDate: oldAccumulatedSymbol.firstBuyDate, + investment: newQuantity.eq(0) + ? new Big(0) + : unitPrice + .mul(order.quantity) + .mul(factor) + .add(oldAccumulatedSymbol.investment), + quantity: newQuantity, + symbol: order.symbol, + transactionCount: oldAccumulatedSymbol.transactionCount + 1 + }; + } else { + currentTransactionPointItem = { + currency: order.currency, + dataSource: order.dataSource, + fee: order.fee, + firstBuyDate: order.date, + investment: unitPrice.mul(order.quantity).mul(factor), + quantity: order.quantity.mul(factor), + symbol: order.symbol, + 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) => 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; + } + } + + public getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercent + }: { + daysInMarket: number; + netPerformancePercent: Big; + }): Big { + if (isNumber(daysInMarket) && daysInMarket > 0) { + return netPerformancePercent.mul(daysInMarket).div(365); + } + + return new Big(0); + } + + public getTransactionPoints(): TransactionPoint[] { + return this.transactionPoints; + } + + public setTransactionPoints(transactionPoints: TransactionPoint[]) { + this.transactionPoints = transactionPoints; + } + + public async getCurrentPositions(start: Date): Promise { + if (!this.transactionPoints?.length) { + return { + currentValue: new Big(0), + hasErrors: false, + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + netAnnualizedPerformance: new Big(0), + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + positions: [], + totalInvestment: new Big(0) + }; + } + + const lastTransactionPoint = + this.transactionPoints[this.transactionPoints.length - 1]; + + // use Date.now() to use the mock for today + const today = new Date(Date.now()); + + let firstTransactionPoint: TransactionPoint = null; + let firstIndex = this.transactionPoints.length; + const dates = []; + const dataGatheringItems: IDataGatheringItem[] = []; + const currencies: { [symbol: string]: string } = {}; + + dates.push(resetHours(start)); + for (const item of this.transactionPoints[firstIndex - 1].items) { + dataGatheringItems.push({ + dataSource: item.dataSource, + symbol: item.symbol + }); + currencies[item.symbol] = item.currency; + } + for (let i = 0; i < this.transactionPoints.length; i++) { + if ( + !isBefore(parseDate(this.transactionPoints[i].date), start) && + firstTransactionPoint === null + ) { + firstTransactionPoint = this.transactionPoints[i]; + firstIndex = i; + } + if (firstTransactionPoint !== null) { + dates.push(resetHours(parseDate(this.transactionPoints[i].date))); + } + } + + dates.push(resetHours(today)); + + const marketSymbols = await this.currentRateService.getValues({ + currencies, + dataGatheringItems, + dateQuery: { + in: dates + }, + userCurrency: this.currency + }); + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + const todayString = format(today, DATE_FORMAT); + + if (firstIndex > 0) { + firstIndex--; + } + const initialValues: { [symbol: string]: Big } = {}; + + const positions: TimelinePosition[] = []; + let hasErrorsInSymbolMetrics = false; + + for (const item of lastTransactionPoint.items) { + const marketValue = marketSymbolMap[todayString]?.[item.symbol]; + + const { + // annualizedGrossPerformance, + // annualizedNetPerformance, + grossPerformance, + grossPerformancePercentage, + hasErrors, + initialValue, + netPerformance, + netPerformancePercentage + } = this.getSymbolMetrics({ + marketSymbolMap, + start, + symbol: item.symbol + }); + + hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors; + + initialValues[item.symbol] = initialValue; + + positions.push({ + averagePrice: item.quantity.eq(0) + ? new Big(0) + : item.investment.div(item.quantity), + currency: item.currency, + dataSource: item.dataSource, + firstBuyDate: item.firstBuyDate, + grossPerformance: !hasErrors ? grossPerformance ?? null : null, + grossPerformancePercentage: !hasErrors + ? grossPerformancePercentage ?? null + : null, + investment: item.investment, + marketPrice: marketValue?.toNumber() ?? null, + netPerformance: !hasErrors ? netPerformance ?? null : null, + netPerformancePercentage: !hasErrors + ? netPerformancePercentage ?? null + : null, + quantity: item.quantity, + symbol: item.symbol, + transactionCount: item.transactionCount + }); + } + const overall = this.calculateOverallPerformance(positions, initialValues); + + return { + ...overall, + positions, + hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors + }; + } + + public getSymbolMetrics({ + marketSymbolMap, + start, + symbol + }: { + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + symbol: string; + }) { + let orders: PortfolioOrderItem[] = this.orders.filter((order) => { + return order.symbol === symbol; + }); + + if (orders.length <= 0) { + return { + hasErrors: false, + initialValue: new Big(0), + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0) + }; + } + + const dateOfFirstTransaction = new Date(first(orders).date); + const endDate = new Date(Date.now()); + + const unitPriceAtStartDate = + marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; + + const unitPriceAtEndDate = + marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol]; + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) + ) { + return { + hasErrors: true, + initialValue: new Big(0), + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0) + }; + } + + let feesAtStartDate = new Big(0); + let fees = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceFromSells = new Big(0); + let initialValue: Big; + let lastAveragePrice = new Big(0); + let lastValueOfInvestment = new Big(0); + let lastNetValueOfInvestment = new Big(0); + let previousOrder: PortfolioOrder = null; + let timeWeightedGrossPerformancePercentage = new Big(1); + let timeWeightedNetPerformancePercentage = new Big(1); + let totalInvestment = new Big(0); + let totalUnits = new Big(0); + + // 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), + itemType: 'start', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtStartDate ?? new Big(0) + }); + + orders.push({ + symbol, + currency: null, + date: format(endDate, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + itemType: 'end', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtEndDate ?? new Big(0) + }); + + // Sort orders so that the start and end placeholder order are at the right + // position + orders = sortBy(orders, (order) => { + let sortIndex = new Date(order.date); + + if (order.itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + if (order.itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } + + return sortIndex.getTime(); + }); + + const indexOfStartOrder = orders.findIndex((order) => { + return order.itemType === 'start'; + }); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + const transactionInvestment = order.quantity.mul(order.unitPrice); + + if ( + !initialValue && + order.itemType !== 'start' && + order.itemType !== 'end' + ) { + initialValue = transactionInvestment; + } + + fees = fees.plus(order.fee); + + totalUnits = totalUnits.plus( + order.quantity.mul(this.getFactor(order.type)) + ); + + const valueOfInvestment = totalUnits.mul(order.unitPrice); + const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees); + + const grossPerformanceFromSell = + order.type === TypeOfOrder.SELL + ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + totalInvestment = totalInvestment + .plus(transactionInvestment.mul(this.getFactor(order.type))) + .plus(grossPerformanceFromSell); + + lastAveragePrice = totalUnits.eq(0) + ? new Big(0) + : totalInvestment.div(totalUnits); + + const newGrossPerformance = valueOfInvestment + .minus(totalInvestment) + .plus(grossPerformanceFromSells); + + const grossPerformanceSinceLastTransaction = + newGrossPerformance.minus(grossPerformance); + + const netPerformanceSinceLastTransaction = + grossPerformanceSinceLastTransaction.minus(previousOrder?.fee ?? 0); + + if ( + i > indexOfStartOrder && + !lastValueOfInvestment + .plus(transactionInvestment.mul(this.getFactor(order.type))) + .eq(0) + ) { + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.mul( + new Big(1).plus( + valueOfInvestment + .minus( + lastValueOfInvestment.plus( + transactionInvestment.mul(this.getFactor(order.type)) + ) + ) + .div( + lastValueOfInvestment.plus( + transactionInvestment.mul(this.getFactor(order.type)) + ) + ) + ) + ); + + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.mul( + new Big(1).plus( + netValueOfInvestment + .minus( + lastNetValueOfInvestment.plus( + transactionInvestment.mul(this.getFactor(order.type)) + ) + ) + .div( + lastNetValueOfInvestment.plus( + transactionInvestment.mul(this.getFactor(order.type)) + ) + ) + ) + ); + } + + grossPerformance = newGrossPerformance; + lastNetValueOfInvestment = netValueOfInvestment; + lastValueOfInvestment = valueOfInvestment; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + grossPerformanceAtStartDate = grossPerformance; + } + + /*console.log(` + Symbol: ${symbol} + Date: ${order.date} + Price: ${order.unitPrice} + transactionInvestment: ${transactionInvestment} + totalUnits: ${totalUnits} + totalInvestment: ${totalInvestment} + valueOfInvestment: ${valueOfInvestment} + lastAveragePrice: ${lastAveragePrice} + grossPerformanceFromSell: ${grossPerformanceFromSell} + grossPerformanceFromSells: ${grossPerformanceFromSells} + grossPerformance: ${grossPerformance.minus(grossPerformanceAtStartDate)} + netPerformance: ${grossPerformance.minus(fees)} + netPerformanceSinceLastTransaction: ${netPerformanceSinceLastTransaction} + grossPerformanceSinceLastTransaction: ${grossPerformanceSinceLastTransaction} + timeWeightedGrossPerformancePercentage: ${timeWeightedGrossPerformancePercentage} + timeWeightedNetPerformancePercentage: ${timeWeightedNetPerformancePercentage} + `);*/ + + previousOrder = order; + } + + // console.log('\n---\n'); + + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.sub(1); + + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.sub(1); + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + return { + hasErrors: !initialValue || !unitPriceAtEndDate, + initialValue, + netPerformance: totalNetPerformance, + netPerformancePercentage: timeWeightedNetPerformancePercentage, + grossPerformance: totalGrossPerformance, + grossPerformancePercentage: timeWeightedGrossPerformancePercentage + }; + } + + public getInvestments(): { date: string; investment: Big }[] { + if (this.transactionPoints.length === 0) { + return []; + } + + return this.transactionPoints.map((transactionPoint) => { + return { + date: transactionPoint.date, + investment: transactionPoint.items.reduce( + (investment, transactionPointSymbol) => + investment.add(transactionPointSymbol.investment), + new Big(0) + ) + }; + }); + } + + public async calculateTimeline( + timelineSpecification: TimelineSpecification[], + endDate: string + ): Promise { + if (timelineSpecification.length === 0) { + return { + maxNetPerformance: new Big(0), + minNetPerformance: new Big(0), + timelinePeriods: [] + }; + } + + const startDate = timelineSpecification[0].start; + const start = parseDate(startDate); + const end = parseDate(endDate); + + const timelinePeriodPromises: Promise[] = []; + let i = 0; + let j = -1; + for ( + let currentDate = start; + !isAfter(currentDate, end); + currentDate = this.addToDate( + currentDate, + timelineSpecification[i].accuracy + ) + ) { + if (this.isNextItemActive(timelineSpecification, currentDate, i)) { + i++; + } + while ( + j + 1 < this.transactionPoints.length && + !isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate) + ) { + j++; + } + + let periodEndDate = currentDate; + if (timelineSpecification[i].accuracy === 'day') { + let nextEndDate = end; + if (j + 1 < this.transactionPoints.length) { + nextEndDate = parseDate(this.transactionPoints[j + 1].date); + } + periodEndDate = min([ + addMonths(currentDate, 3), + max([currentDate, nextEndDate]) + ]); + } + const timePeriodForDates = this.getTimePeriodForDate( + j, + currentDate, + endOfDay(periodEndDate) + ); + currentDate = periodEndDate; + if (timePeriodForDates != null) { + timelinePeriodPromises.push(timePeriodForDates); + } + } + + const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all( + timelinePeriodPromises + ); + const minNetPerformance = timelineInfoInterfaces + .map((timelineInfo) => timelineInfo.minNetPerformance) + .filter((performance) => performance !== null) + .reduce((minPerformance, current) => { + if (minPerformance.lt(current)) { + return minPerformance; + } else { + return current; + } + }); + + const maxNetPerformance = timelineInfoInterfaces + .map((timelineInfo) => timelineInfo.maxNetPerformance) + .filter((performance) => performance !== null) + .reduce((maxPerformance, current) => { + if (maxPerformance.gt(current)) { + return maxPerformance; + } else { + return current; + } + }); + + const timelinePeriods = timelineInfoInterfaces.map( + (timelineInfo) => timelineInfo.timelinePeriods + ); + + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: flatten(timelinePeriods) + }; + } + + private calculateOverallPerformance( + positions: TimelinePosition[], + initialValues: { [p: string]: Big } + ) { + let hasErrors = false; + let currentValue = new Big(0); + let totalInvestment = new Big(0); + let grossPerformance = new Big(0); + let grossPerformancePercentage = new Big(0); + let netPerformance = new Big(0); + let netPerformancePercentage = new Big(0); + let completeInitialValue = new Big(0); + let netAnnualizedPerformance = new Big(0); + + // use Date.now() to use the mock for today + const today = new Date(Date.now()); + + for (const currentPosition of positions) { + if (currentPosition.marketPrice) { + currentValue = currentValue.add( + new Big(currentPosition.marketPrice).mul(currentPosition.quantity) + ); + } else { + hasErrors = true; + } + totalInvestment = totalInvestment.add(currentPosition.investment); + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + netPerformance = netPerformance.plus(currentPosition.netPerformance); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if ( + currentPosition.grossPerformancePercentage && + initialValues[currentPosition.symbol] + ) { + const currentInitialValue = initialValues[currentPosition.symbol]; + completeInitialValue = completeInitialValue.plus(currentInitialValue); + grossPerformancePercentage = grossPerformancePercentage.plus( + currentPosition.grossPerformancePercentage.mul(currentInitialValue) + ); + + netAnnualizedPerformance = netAnnualizedPerformance.plus( + this.getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + today, + parseDate(currentPosition.firstBuyDate) + ), + netPerformancePercent: currentPosition.netPerformancePercentage + }).mul(currentInitialValue) + ); + netPerformancePercentage = netPerformancePercentage.plus( + currentPosition.netPerformancePercentage.mul(currentInitialValue) + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}` + ); + hasErrors = true; + } + } + + if (!completeInitialValue.eq(0)) { + grossPerformancePercentage = + grossPerformancePercentage.div(completeInitialValue); + netPerformancePercentage = + netPerformancePercentage.div(completeInitialValue); + netAnnualizedPerformance = + netAnnualizedPerformance.div(completeInitialValue); + } + + return { + currentValue, + grossPerformance, + grossPerformancePercentage, + hasErrors, + netAnnualizedPerformance, + netPerformance, + netPerformancePercentage, + totalInvestment + }; + } + + private async getTimePeriodForDate( + j: number, + startDate: Date, + endDate: Date + ): Promise { + let investment: Big = new Big(0); + let fees: Big = new Big(0); + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + if (j >= 0) { + const currencies: { [name: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + + for (const item of this.transactionPoints[j].items) { + currencies[item.symbol] = item.currency; + dataGatheringItems.push({ + dataSource: item.dataSource, + symbol: item.symbol + }); + investment = investment.add(item.investment); + fees = fees.add(item.fee); + } + + let marketSymbols: GetValueObject[] = []; + if (dataGatheringItems.length > 0) { + try { + marketSymbols = await this.currentRateService.getValues({ + currencies, + dataGatheringItems, + dateQuery: { + gte: startDate, + lt: endOfDay(endDate) + }, + userCurrency: this.currency + }); + } catch (error) { + Logger.error( + `Failed to fetch info for date ${startDate} with exception`, + error + ); + return null; + } + } + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + } + + const results: TimelinePeriod[] = []; + let maxNetPerformance: Big = null; + let minNetPerformance: Big = null; + for ( + let currentDate = startDate; + isBefore(currentDate, endDate); + currentDate = addDays(currentDate, 1) + ) { + let value = new Big(0); + const currentDateAsString = format(currentDate, DATE_FORMAT); + let invalid = false; + if (j >= 0) { + for (const item of this.transactionPoints[j].items) { + if ( + !marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol) + ) { + invalid = true; + break; + } + value = value.add( + item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol]) + ); + } + } + if (!invalid) { + const grossPerformance = value.minus(investment); + const netPerformance = grossPerformance.minus(fees); + if ( + minNetPerformance === null || + minNetPerformance.gt(netPerformance) + ) { + minNetPerformance = netPerformance; + } + if ( + maxNetPerformance === null || + maxNetPerformance.lt(netPerformance) + ) { + maxNetPerformance = netPerformance; + } + + const result = { + grossPerformance, + investment, + netPerformance, + value, + date: currentDateAsString + }; + results.push(result); + } + } + + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: results + }; + } + + private getFactor(type: TypeOfOrder) { + let factor: number; + + switch (type) { + case 'BUY': + factor = 1; + break; + case 'SELL': + factor = -1; + break; + default: + factor = 0; + break; + } + + return factor; + } + + private addToDate(date: Date, accuracy: Accuracy): Date { + switch (accuracy) { + case 'day': + return addDays(date, 1); + case 'month': + return addMonths(date, 1); + case 'year': + return addYears(date, 1); + } + } + + private isNextItemActive( + timelineSpecification: TimelineSpecification[], + currentDate: Date, + i: number + ) { + return ( + i + 1 < timelineSpecification.length && + !isBefore(currentDate, parseDate(timelineSpecification[i + 1].start)) + ); + } +} diff --git a/apps/api/src/app/portfolio/portfolio-service.factory.ts b/apps/api/src/app/portfolio/portfolio-service.factory.ts new file mode 100644 index 000000000..e5a3eaae5 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-service.factory.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { PortfolioService } from './portfolio.service'; +import { PortfolioServiceNew } from './portfolio.service-new'; + +@Injectable() +export class PortfolioServiceFactory { + public constructor( + private readonly portfolioService: PortfolioService, + private readonly portfolioServiceNew: PortfolioServiceNew + ) {} + + public get() { + if (false) { + return this.portfolioServiceNew; + } + + return this.portfolioService; + } +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 44f4f1e77..9aae2f772 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -35,7 +35,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; -import { PortfolioService } from './portfolio.service'; +import { PortfolioServiceFactory } from './portfolio-service.factory'; @Controller('portfolio') export class PortfolioController { @@ -43,7 +43,7 @@ export class PortfolioController { private readonly accessService: AccessService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly portfolioService: PortfolioService, + private readonly portfolioServiceFactory: PortfolioServiceFactory, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -55,10 +55,9 @@ export class PortfolioController { @Query('range') range, @Res() res: Response ): Promise { - const historicalDataContainer = await this.portfolioService.getChart( - impersonationId, - range - ); + const historicalDataContainer = await this.portfolioServiceFactory + .get() + .getChart(impersonationId, range); let chartData = historicalDataContainer.items; @@ -115,12 +114,9 @@ export class PortfolioController { let hasError = false; - const { accounts, holdings, hasErrors } = - await this.portfolioService.getDetails( - impersonationId, - this.request.user.id, - range - ); + const { accounts, holdings, hasErrors } = await this.portfolioServiceFactory + .get() + .getDetails(impersonationId, this.request.user.id, range); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { hasError = true; @@ -178,9 +174,9 @@ export class PortfolioController { return res.json({}); } - let investments = await this.portfolioService.getInvestments( - impersonationId - ); + let investments = await this.portfolioServiceFactory + .get() + .getInvestments(impersonationId); if ( impersonationId || @@ -207,10 +203,9 @@ export class PortfolioController { @Query('range') range, @Res() res: Response ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { - const performanceInformation = await this.portfolioService.getPerformance( - impersonationId, - range - ); + const performanceInformation = await this.portfolioServiceFactory + .get() + .getPerformance(impersonationId, range); if ( impersonationId || @@ -232,10 +227,9 @@ export class PortfolioController { @Query('range') range, @Res() res: Response ): Promise { - const result = await this.portfolioService.getPositions( - impersonationId, - range - ); + const result = await this.portfolioServiceFactory + .get() + .getPositions(impersonationId, range); if ( impersonationId || @@ -274,10 +268,9 @@ export class PortfolioController { hasDetails = user.subscription.type === 'Premium'; } - const { holdings } = await this.portfolioService.getDetails( - access.userId, - access.userId - ); + const { holdings } = await this.portfolioServiceFactory + .get() + .getDetails(access.userId, access.userId); const portfolioPublicDetails: PortfolioPublicDetails = { hasDetails, @@ -318,7 +311,9 @@ export class PortfolioController { public async getSummary( @Headers('impersonation-id') impersonationId ): Promise { - let summary = await this.portfolioService.getSummary(impersonationId); + let summary = await this.portfolioServiceFactory + .get() + .getSummary(impersonationId); if ( impersonationId || @@ -347,10 +342,9 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId: string, @Param('symbol') symbol ): Promise { - let position = await this.portfolioService.getPosition( - impersonationId, - symbol - ); + let position = await this.portfolioServiceFactory + .get() + .getPosition(impersonationId, symbol); if (position) { if ( @@ -391,7 +385,9 @@ export class PortfolioController { } return ( - res.json(await this.portfolioService.getReport(impersonationId)) + res.json( + await this.portfolioServiceFactory.get().getReport(impersonationId) + ) ); } } diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 515330516..d7708a86d 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod import { Module } from '@nestjs/common'; import { CurrentRateService } from './current-rate.service'; +import { PortfolioServiceFactory } from './portfolio-service.factory'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; +import { PortfolioServiceNew } from './portfolio.service-new'; import { RulesService } from './rules.service'; @Module({ - exports: [PortfolioService], + exports: [PortfolioServiceFactory], imports: [ AccessModule, ConfigurationModule, @@ -37,6 +39,8 @@ import { RulesService } from './rules.service'; AccountService, CurrentRateService, PortfolioService, + PortfolioServiceNew, + PortfolioServiceFactory, RulesService ] }) diff --git a/apps/api/src/app/portfolio/portfolio.service-new.ts b/apps/api/src/app/portfolio/portfolio.service-new.ts new file mode 100644 index 000000000..736385da0 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service-new.ts @@ -0,0 +1,1190 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.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 { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; +import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; +import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; +import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; +import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; +import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; +import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment'; +import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; +import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; +import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; +import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; +import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; +import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { + Accounts, + PortfolioDetails, + PortfolioPerformance, + PortfolioReport, + PortfolioSummary, + Position, + TimelinePosition +} from '@ghostfolio/common/interfaces'; +import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import type { + AccountWithValue, + DateRange, + OrderWithAccount, + RequestWithUser +} from '@ghostfolio/common/types'; +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; +import Big from 'big.js'; +import { + endOfToday, + format, + isAfter, + isBefore, + max, + parse, + parseISO, + setDayOfYear, + startOfDay, + subDays, + subYears +} from 'date-fns'; +import { isEmpty, sortBy } from 'lodash'; + +import { + HistoricalDataContainer, + HistoricalDataItem, + PortfolioPositionDetail +} from './interfaces/portfolio-position-detail.interface'; +import { PortfolioCalculatorNew } from './portfolio-calculator-new'; +import { RulesService } from './rules.service'; + +@Injectable() +export class PortfolioServiceNew { + public constructor( + private readonly accountService: AccountService, + private readonly currentRateService: CurrentRateService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly rulesService: RulesService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async getAccounts(aUserId: string): Promise { + const [accounts, details] = await Promise.all([ + this.accountService.accounts({ + include: { Order: true, Platform: true }, + orderBy: { name: 'asc' }, + where: { userId: aUserId } + }), + this.getDetails(aUserId, aUserId) + ]); + + const userCurrency = this.request.user.Settings.currency; + + return accounts.map((account) => { + let transactionCount = 0; + + for (const order of account.Order) { + if (!order.isDraft) { + transactionCount += 1; + } + } + + const result = { + ...account, + transactionCount, + convertedBalance: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ), + value: details.accounts[account.id]?.current ?? 0 + }; + + delete result.Order; + + return result; + }); + } + + public async getAccountsWithAggregations(aUserId: string): Promise { + const accounts = await this.getAccounts(aUserId); + let totalBalance = 0; + let totalValue = 0; + let transactionCount = 0; + + for (const account of accounts) { + totalBalance += account.convertedBalance; + totalValue += account.value; + transactionCount += account.transactionCount; + } + + return { accounts, totalBalance, totalValue, transactionCount }; + } + + public async getInvestments( + aImpersonationId: string + ): Promise { + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId, + includeDrafts: true + }); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + if (transactionPoints.length === 0) { + return []; + } + + const investments = portfolioCalculator.getInvestments().map((item) => { + return { + date: item.date, + investment: item.investment.toNumber() + }; + }); + + // Add investment of today + const investmentOfToday = investments.filter((investment) => { + return investment.date === format(new Date(), DATE_FORMAT); + }); + + if (investmentOfToday.length <= 0) { + const pastInvestments = investments.filter((investment) => { + return isBefore(parseDate(investment.date), new Date()); + }); + const lastInvestment = pastInvestments[pastInvestments.length - 1]; + + investments.push({ + date: format(new Date(), DATE_FORMAT), + investment: lastInvestment?.investment ?? 0 + }); + } + + return sortBy(investments, (investment) => { + return investment.date; + }); + } + + public async getChart( + aImpersonationId: string, + aDateRange: DateRange = 'max' + ): Promise { + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + if (transactionPoints.length === 0) { + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: [] + }; + } + let portfolioStart = parse( + transactionPoints[0].date, + DATE_FORMAT, + new Date() + ); + + // Get start date for the full portfolio because of because of the + // min and max calculation + portfolioStart = this.getStartDate('max', portfolioStart); + + const timelineSpecification: TimelineSpecification[] = [ + { + start: format(portfolioStart, DATE_FORMAT), + accuracy: 'day' + } + ]; + + const timelineInfo = await portfolioCalculator.calculateTimeline( + timelineSpecification, + format(new Date(), DATE_FORMAT) + ); + + const timeline = timelineInfo.timelinePeriods; + + const items = timeline + .filter((timelineItem) => timelineItem !== null) + .map((timelineItem) => ({ + date: timelineItem.date, + marketPrice: timelineItem.value, + value: timelineItem.netPerformance.toNumber() + })); + + let lastItem = null; + if (timeline.length > 0) { + lastItem = timeline[timeline.length - 1]; + } + + let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq( + lastItem?.netPerformance + ); + let isAllTimeLow = timelineInfo.minNetPerformance?.eq( + lastItem?.netPerformance + ); + if (isAllTimeHigh && isAllTimeLow) { + isAllTimeHigh = false; + isAllTimeLow = false; + } + + portfolioStart = startOfDay( + this.getStartDate( + aDateRange, + parse(transactionPoints[0].date, DATE_FORMAT, new Date()) + ) + ); + + return { + isAllTimeHigh, + isAllTimeLow, + items: items.filter((item) => { + // Filter items of date range + return !isAfter(portfolioStart, parseDate(item.date)); + }) + }; + } + + public async getDetails( + aImpersonationId: string, + aUserId: string, + aDateRange: DateRange = 'max' + ): Promise { + const userId = await this.getUserId(aImpersonationId, aUserId); + + const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; + + const { orders, portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + if (transactionPoints?.length <= 0) { + return { accounts: {}, holdings: {}, hasErrors: false }; + } + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); + + const cashDetails = await this.accountService.getCashDetails( + userId, + userCurrency + ); + + const holdings: PortfolioDetails['holdings'] = {}; + const totalInvestment = currentPositions.totalInvestment.plus( + cashDetails.balance + ); + const totalValue = currentPositions.currentValue.plus(cashDetails.balance); + + const dataGatheringItems = currentPositions.positions.map((position) => { + return { + dataSource: position.dataSource, + symbol: position.symbol + }; + }); + const symbols = currentPositions.positions.map( + (position) => position.symbol + ); + + const [dataProviderResponses, symbolProfiles] = await Promise.all([ + this.dataProviderService.get(dataGatheringItems), + this.symbolProfileService.getSymbolProfiles(symbols) + ]); + + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + for (const symbolProfile of symbolProfiles) { + symbolProfileMap[symbolProfile.symbol] = symbolProfile; + } + + const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + for (const position of currentPositions.positions) { + portfolioItemsNow[position.symbol] = position; + } + + for (const item of currentPositions.positions) { + if (item.quantity.lte(0)) { + // Ignore positions without any quantity + continue; + } + + const value = item.quantity.mul(item.marketPrice); + const symbolProfile = symbolProfileMap[item.symbol]; + const dataProviderResponse = dataProviderResponses[item.symbol]; + holdings[item.symbol] = { + allocationCurrent: value.div(totalValue).toNumber(), + allocationInvestment: item.investment.div(totalInvestment).toNumber(), + assetClass: symbolProfile.assetClass, + assetSubClass: symbolProfile.assetSubClass, + countries: symbolProfile.countries, + currency: item.currency, + exchange: dataProviderResponse.exchange, + grossPerformance: item.grossPerformance?.toNumber() ?? 0, + grossPerformancePercent: + item.grossPerformancePercentage?.toNumber() ?? 0, + investment: item.investment.toNumber(), + marketPrice: item.marketPrice, + marketState: dataProviderResponse.marketState, + name: symbolProfile.name, + netPerformance: item.netPerformance?.toNumber() ?? 0, + netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, + quantity: item.quantity.toNumber(), + sectors: symbolProfile.sectors, + symbol: item.symbol, + transactionCount: item.transactionCount, + value: value.toNumber() + }; + } + + const cashPositions = await this.getCashPositions({ + cashDetails, + userCurrency, + investment: totalInvestment, + value: totalValue + }); + + for (const symbol of Object.keys(cashPositions)) { + holdings[symbol] = cashPositions[symbol]; + } + + const accounts = await this.getValueOfAccounts( + orders, + portfolioItemsNow, + userCurrency, + userId + ); + + return { accounts, holdings, hasErrors: currentPositions.hasErrors }; + } + + public async getPosition( + aImpersonationId: string, + aSymbol: string + ): Promise { + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const orders = (await this.orderService.getOrders({ userId })).filter( + (order) => order.symbol === aSymbol + ); + + if (orders.length <= 0) { + return { + averagePrice: undefined, + currency: undefined, + firstBuyDate: undefined, + grossPerformance: undefined, + grossPerformancePercent: undefined, + historicalData: [], + investment: undefined, + marketPrice: undefined, + maxPrice: undefined, + minPrice: undefined, + name: undefined, + netPerformance: undefined, + netPerformancePercent: undefined, + orders: [], + quantity: undefined, + symbol: aSymbol, + transactionCount: undefined, + value: undefined + }; + } + + const assetClass = orders[0].SymbolProfile?.assetClass; + const assetSubClass = orders[0].SymbolProfile?.assetSubClass; + const positionCurrency = orders[0].currency; + const name = orders[0].SymbolProfile?.name ?? ''; + + const portfolioOrders: PortfolioOrder[] = orders + .filter((order) => { + return order.type === 'BUY' || order.type === 'SELL'; + }) + .map((order) => ({ + currency: order.currency, + dataSource: order.SymbolProfile?.dataSource ?? order.dataSource, + date: format(order.date, DATE_FORMAT), + fee: new Big(order.fee), + name: order.SymbolProfile?.name, + quantity: new Big(order.quantity), + symbol: order.symbol, + type: order.type, + unitPrice: new Big(order.unitPrice) + })); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: positionCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.computeTransactionPoints(); + const transactionPoints = portfolioCalculator.getTransactionPoints(); + + const portfolioStart = parseDate(transactionPoints[0].date); + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioStart + ); + + const position = currentPositions.positions.find( + (item) => item.symbol === aSymbol + ); + + if (position) { + const { + averagePrice, + currency, + dataSource, + firstBuyDate, + marketPrice, + quantity, + transactionCount + } = position; + + // Convert investment, gross and net performance to currency of user + const userCurrency = this.request.user.Settings.currency; + const investment = this.exchangeRateDataService.toCurrency( + position.investment?.toNumber(), + currency, + userCurrency + ); + const grossPerformance = this.exchangeRateDataService.toCurrency( + position.grossPerformance?.toNumber(), + currency, + userCurrency + ); + const netPerformance = this.exchangeRateDataService.toCurrency( + position.netPerformance?.toNumber(), + currency, + userCurrency + ); + + const historicalData = await this.dataProviderService.getHistorical( + [{ dataSource, symbol: aSymbol }], + 'day', + parseISO(firstBuyDate), + new Date() + ); + + const historicalDataArray: HistoricalDataItem[] = []; + let maxPrice = Math.max(orders[0].unitPrice, marketPrice); + let minPrice = Math.min(orders[0].unitPrice, marketPrice); + + if (!historicalData?.[aSymbol]?.[firstBuyDate]) { + // Add historical entry for buy date, if no historical data available + historicalDataArray.push({ + averagePrice: orders[0].unitPrice, + date: firstBuyDate, + value: orders[0].unitPrice + }); + } + + if (historicalData[aSymbol]) { + let j = -1; + for (const [date, { marketPrice }] of Object.entries( + historicalData[aSymbol] + )) { + while ( + j + 1 < transactionPoints.length && + !isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date)) + ) { + j++; + } + let currentAveragePrice = 0; + const currentSymbol = transactionPoints[j].items.find( + (item) => item.symbol === aSymbol + ); + if (currentSymbol) { + currentAveragePrice = currentSymbol.quantity.eq(0) + ? 0 + : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + } + + historicalDataArray.push({ + date, + averagePrice: currentAveragePrice, + value: marketPrice + }); + + maxPrice = Math.max(marketPrice ?? 0, maxPrice); + minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); + } + } + + return { + assetClass, + assetSubClass, + currency, + firstBuyDate, + grossPerformance, + investment, + marketPrice, + maxPrice, + minPrice, + name, + netPerformance, + orders, + transactionCount, + averagePrice: averagePrice.toNumber(), + grossPerformancePercent: + position.grossPerformancePercentage?.toNumber(), + historicalData: historicalDataArray, + netPerformancePercent: position.netPerformancePercentage?.toNumber(), + quantity: quantity.toNumber(), + symbol: aSymbol, + value: this.exchangeRateDataService.toCurrency( + quantity.mul(marketPrice).toNumber(), + currency, + userCurrency + ) + }; + } else { + const currentData = await this.dataProviderService.get([ + { dataSource: DataSource.YAHOO, symbol: aSymbol } + ]); + const marketPrice = currentData[aSymbol]?.marketPrice; + + let historicalData = await this.dataProviderService.getHistorical( + [{ dataSource: DataSource.YAHOO, symbol: aSymbol }], + 'day', + portfolioStart, + new Date() + ); + + if (isEmpty(historicalData)) { + historicalData = await this.dataProviderService.getHistoricalRaw( + [{ dataSource: DataSource.YAHOO, symbol: aSymbol }], + portfolioStart, + new Date() + ); + } + + const historicalDataArray: HistoricalDataItem[] = []; + let maxPrice = marketPrice; + let minPrice = marketPrice; + + for (const [date, { marketPrice }] of Object.entries( + historicalData[aSymbol] + )) { + historicalDataArray.push({ + date, + value: marketPrice + }); + + maxPrice = Math.max(marketPrice ?? 0, maxPrice); + minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); + } + + return { + assetClass, + assetSubClass, + marketPrice, + maxPrice, + minPrice, + name, + orders, + averagePrice: 0, + currency: currentData[aSymbol]?.currency, + firstBuyDate: undefined, + grossPerformance: undefined, + grossPerformancePercent: undefined, + historicalData: historicalDataArray, + investment: 0, + netPerformance: undefined, + netPerformancePercent: undefined, + quantity: 0, + symbol: aSymbol, + transactionCount: undefined, + value: 0 + }; + } + } + + public async getPositions( + aImpersonationId: string, + aDateRange: DateRange = 'max' + ): Promise<{ hasErrors: boolean; positions: Position[] }> { + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + if (transactionPoints?.length <= 0) { + return { + hasErrors: false, + positions: [] + }; + } + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); + + const positions = currentPositions.positions.filter( + (item) => !item.quantity.eq(0) + ); + const dataGatheringItem = positions.map((position) => { + return { + dataSource: position.dataSource, + symbol: position.symbol + }; + }); + const symbols = positions.map((position) => position.symbol); + + const [dataProviderResponses, symbolProfiles] = await Promise.all([ + this.dataProviderService.get(dataGatheringItem), + this.symbolProfileService.getSymbolProfiles(symbols) + ]); + + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + for (const symbolProfile of symbolProfiles) { + symbolProfileMap[symbolProfile.symbol] = symbolProfile; + } + + return { + hasErrors: currentPositions.hasErrors, + positions: positions.map((position) => { + return { + ...position, + assetClass: symbolProfileMap[position.symbol].assetClass, + averagePrice: new Big(position.averagePrice).toNumber(), + grossPerformance: position.grossPerformance?.toNumber() ?? null, + grossPerformancePercentage: + position.grossPerformancePercentage?.toNumber() ?? null, + investment: new Big(position.investment).toNumber(), + marketState: + dataProviderResponses[position.symbol]?.marketState ?? + MarketState.delayed, + name: symbolProfileMap[position.symbol].name, + netPerformance: position.netPerformance?.toNumber() ?? null, + netPerformancePercentage: + position.netPerformancePercentage?.toNumber() ?? null, + quantity: new Big(position.quantity).toNumber() + }; + }) + }; + } + + public async getPerformance( + aImpersonationId: string, + aDateRange: DateRange = 'max' + ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + if (transactionPoints?.length <= 0) { + return { + hasErrors: false, + performance: { + annualizedPerformancePercent: 0, + currentGrossPerformance: 0, + currentGrossPerformancePercent: 0, + currentNetPerformance: 0, + currentNetPerformancePercent: 0, + currentValue: 0 + } + }; + } + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); + + const hasErrors = currentPositions.hasErrors; + const annualizedPerformancePercent = + currentPositions.netAnnualizedPerformance.toNumber(); + const currentValue = currentPositions.currentValue.toNumber(); + const currentGrossPerformance = + currentPositions.grossPerformance.toNumber(); + const currentGrossPerformancePercent = + currentPositions.grossPerformancePercentage.toNumber(); + const currentNetPerformance = currentPositions.netPerformance.toNumber(); + const currentNetPerformancePercent = + currentPositions.netPerformancePercentage.toNumber(); + + return { + hasErrors: currentPositions.hasErrors || hasErrors, + performance: { + annualizedPerformancePercent, + currentGrossPerformance, + currentGrossPerformancePercent, + currentNetPerformance, + currentNetPerformancePercent, + currentValue + } + }; + } + + public async getReport(impersonationId: string): Promise { + const currency = this.request.user.Settings.currency; + const userId = await this.getUserId(impersonationId, this.request.user.id); + + const { orders, portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + if (isEmpty(orders)) { + return { + rules: {} + }; + } + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioStart + ); + + const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + for (const position of currentPositions.positions) { + portfolioItemsNow[position.symbol] = position; + } + const accounts = await this.getValueOfAccounts( + orders, + portfolioItemsNow, + currency, + userId + ); + return { + rules: { + accountClusterRisk: await this.rulesService.evaluate( + [ + new AccountClusterRiskInitialInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + { baseCurrency: currency } + ), + currencyClusterRisk: await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyInitialInvestment( + this.exchangeRateDataService, + currentPositions + ), + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + currentPositions + ), + new CurrencyClusterRiskInitialInvestment( + this.exchangeRateDataService, + currentPositions + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + currentPositions + ) + ], + { baseCurrency: currency } + ), + fees: await this.rulesService.evaluate( + [ + new FeeRatioInitialInvestment( + this.exchangeRateDataService, + currentPositions.totalInvestment.toNumber(), + this.getFees(orders).toNumber() + ) + ], + { baseCurrency: currency } + ) + } + }; + } + + public async getSummary(aImpersonationId: string): Promise { + const currency = this.request.user.Settings.currency; + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + + const performanceInformation = await this.getPerformance(aImpersonationId); + + const { balance } = await this.accountService.getCashDetails( + userId, + currency + ); + const orders = await this.orderService.getOrders({ + userId + }); + const dividend = this.getDividend(orders).toNumber(); + const fees = this.getFees(orders).toNumber(); + const firstOrderDate = orders[0]?.date; + + const totalBuy = this.getTotalByType(orders, currency, 'BUY'); + const totalSell = this.getTotalByType(orders, currency, 'SELL'); + + const committedFunds = new Big(totalBuy).sub(totalSell); + + const netWorth = new Big(balance) + .plus(performanceInformation.performance.currentValue) + .toNumber(); + + return { + ...performanceInformation.performance, + dividend, + fees, + firstOrderDate, + netWorth, + totalBuy, + totalSell, + cash: balance, + committedFunds: committedFunds.toNumber(), + ordersCount: orders.filter((order) => { + return order.type === 'BUY' || order.type === 'SELL'; + }).length + }; + } + + private async getCashPositions({ + cashDetails, + investment, + userCurrency, + value + }: { + cashDetails: CashDetails; + investment: Big; + value: Big; + userCurrency: string; + }) { + const cashPositions = {}; + + for (const account of cashDetails.accounts) { + const convertedBalance = this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + + if (convertedBalance === 0) { + continue; + } + + if (cashPositions[account.currency]) { + cashPositions[account.currency].investment += convertedBalance; + cashPositions[account.currency].value += convertedBalance; + } else { + cashPositions[account.currency] = { + allocationCurrent: 0, + allocationInvestment: 0, + assetClass: AssetClass.CASH, + assetSubClass: AssetClass.CASH, + countries: [], + currency: account.currency, + grossPerformance: 0, + grossPerformancePercent: 0, + investment: convertedBalance, + marketPrice: 0, + marketState: MarketState.open, + name: account.currency, + netPerformance: 0, + netPerformancePercent: 0, + quantity: 0, + sectors: [], + symbol: account.currency, + transactionCount: 0, + value: convertedBalance + }; + } + } + + for (const symbol of Object.keys(cashPositions)) { + // Calculate allocations for each currency + cashPositions[symbol].allocationCurrent = new Big( + cashPositions[symbol].value + ) + .div(value) + .toNumber(); + cashPositions[symbol].allocationInvestment = new Big( + cashPositions[symbol].investment + ) + .div(investment) + .toNumber(); + } + + return cashPositions; + } + + private getDividend(orders: OrderWithAccount[], date = new Date(0)) { + return orders + .filter((order) => { + // Filter out all orders before given date and type dividend + return ( + isBefore(date, new Date(order.date)) && + order.type === TypeOfOrder.DIVIDEND + ); + }) + .map((order) => { + return this.exchangeRateDataService.toCurrency( + new Big(order.quantity).mul(order.unitPrice).toNumber(), + order.currency, + this.request.user.Settings.currency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + + private getFees(orders: OrderWithAccount[], date = new Date(0)) { + return orders + .filter((order) => { + // Filter out all orders before given date + return isBefore(date, new Date(order.date)); + }) + .map((order) => { + return this.exchangeRateDataService.toCurrency( + order.fee, + order.currency, + this.request.user.Settings.currency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + + private getStartDate(aDateRange: DateRange, portfolioStart: Date) { + switch (aDateRange) { + case '1d': + portfolioStart = max([portfolioStart, subDays(new Date(), 1)]); + break; + case 'ytd': + portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]); + break; + case '1y': + portfolioStart = max([portfolioStart, subYears(new Date(), 1)]); + break; + case '5y': + portfolioStart = max([portfolioStart, subYears(new Date(), 5)]); + break; + } + return portfolioStart; + } + + private async getTransactionPoints({ + includeDrafts = false, + userId + }: { + includeDrafts?: boolean; + userId: string; + }): Promise<{ + transactionPoints: TransactionPoint[]; + orders: OrderWithAccount[]; + portfolioOrders: PortfolioOrder[]; + }> { + const orders = await this.orderService.getOrders({ + includeDrafts, + userId, + types: ['BUY', 'SELL'] + }); + + if (orders.length <= 0) { + return { transactionPoints: [], orders: [], portfolioOrders: [] }; + } + + const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; + const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ + currency: order.currency, + dataSource: order.SymbolProfile?.dataSource ?? order.dataSource, + date: format(order.date, DATE_FORMAT), + fee: new Big( + this.exchangeRateDataService.toCurrency( + order.fee, + order.currency, + userCurrency + ) + ), + name: order.SymbolProfile?.name, + quantity: new Big(order.quantity), + symbol: order.symbol, + type: order.type, + unitPrice: new Big( + this.exchangeRateDataService.toCurrency( + order.unitPrice, + order.currency, + userCurrency + ) + ) + })); + + const portfolioCalculator = new PortfolioCalculatorNew({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.computeTransactionPoints(); + + return { + transactionPoints: portfolioCalculator.getTransactionPoints(), + orders, + portfolioOrders + }; + } + + private async getValueOfAccounts( + orders: OrderWithAccount[], + portfolioItemsNow: { [p: string]: TimelinePosition }, + userCurrency: string, + userId: string + ) { + const accounts: PortfolioDetails['accounts'] = {}; + + const currentAccounts = await this.accountService.getAccounts(userId); + + for (const account of currentAccounts) { + const ordersByAccount = orders.filter(({ accountId }) => { + return accountId === account.id; + }); + + const convertedBalance = this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + accounts[account.id] = { + balance: convertedBalance, + currency: account.currency, + current: convertedBalance, + name: account.name, + original: convertedBalance + }; + + for (const order of ordersByAccount) { + let currentValueOfSymbol = + order.quantity * portfolioItemsNow[order.symbol].marketPrice; + let originalValueOfSymbol = order.quantity * order.unitPrice; + + if (order.type === 'SELL') { + currentValueOfSymbol *= -1; + originalValueOfSymbol *= -1; + } + + if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) { + accounts[order.Account?.id || UNKNOWN_KEY].current += + currentValueOfSymbol; + accounts[order.Account?.id || UNKNOWN_KEY].original += + originalValueOfSymbol; + } else { + accounts[order.Account?.id || UNKNOWN_KEY] = { + balance: 0, + currency: order.Account?.currency, + current: currentValueOfSymbol, + name: account.name, + original: originalValueOfSymbol + }; + } + } + } + + return accounts; + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + aUserId + ); + + return impersonationUserId || aUserId; + } + + private getTotalByType( + orders: OrderWithAccount[], + currency: string, + type: TypeOfOrder + ) { + return orders + .filter( + (order) => !isAfter(order.date, endOfToday()) && order.type === type + ) + .map((order) => { + return this.exchangeRateDataService.toCurrency( + order.quantity * order.unitPrice, + order.currency, + currency + ); + }) + .reduce((previous, current) => previous + current, 0); + } +}