diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 6b53e5745..44046a60f 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -16,12 +16,11 @@ import { isBefore, isSameMonth, isSameYear, - isWithinInterval, max, min, set } from 'date-fns'; -import { first, flatten, isNumber, sortBy } from 'lodash'; +import { first, flatten, isNumber, last, sortBy } from 'lodash'; import { CurrentRateService } from './current-rate.service'; import { CurrentPositions } from './interfaces/current-positions.interface'; @@ -168,6 +167,131 @@ export class PortfolioCalculator { this.transactionPoints = transactionPoints; } + public async getChartData(start: Date, end = new Date(Date.now()), step = 1) { + const symbols: { [symbol: string]: boolean } = {}; + + const transactionPointsBeforeEndDate = + this.transactionPoints?.filter((transactionPoint) => { + return isBefore(parseDate(transactionPoint.date), end); + }) ?? []; + + const firstIndex = transactionPointsBeforeEndDate.length; + const dates: Date[] = []; + const dataGatheringItems: IDataGatheringItem[] = []; + const currencies: { [symbol: string]: string } = {}; + + let day = start; + + while (isBefore(day, end)) { + dates.push(resetHours(day)); + day = addDays(day, step); + } + + dates.push(resetHours(end)); + + for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { + dataGatheringItems.push({ + dataSource: item.dataSource, + symbol: item.symbol + }); + currencies[item.symbol] = item.currency; + symbols[item.symbol] = true; + } + + 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 dateString = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[dateString]) { + marketSymbolMap[dateString] = {}; + } + if (marketSymbol.marketPriceInBaseCurrency) { + marketSymbolMap[dateString][marketSymbol.symbol] = new Big( + marketSymbol.marketPriceInBaseCurrency + ); + } + } + + const netPerformanceValuesBySymbol: { + [symbol: string]: { [date: string]: Big }; + } = {}; + + const investmentValuesBySymbol: { + [symbol: string]: { [date: string]: Big }; + } = {}; + + const totalNetPerformanceValues: { [date: string]: Big } = {}; + const totalInvestmentValues: { [date: string]: Big } = {}; + + for (const symbol of Object.keys(symbols)) { + const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({ + end, + marketSymbolMap, + start, + step, + symbol, + isChartMode: true + }); + + netPerformanceValuesBySymbol[symbol] = netPerformanceValues; + investmentValuesBySymbol[symbol] = investmentValues; + } + + for (const currentDate of dates) { + const dateString = format(currentDate, DATE_FORMAT); + + for (const symbol of Object.keys(netPerformanceValuesBySymbol)) { + totalNetPerformanceValues[dateString] = + totalNetPerformanceValues[dateString] ?? new Big(0); + + if (netPerformanceValuesBySymbol[symbol]?.[dateString]) { + totalNetPerformanceValues[dateString] = totalNetPerformanceValues[ + dateString + ].add(netPerformanceValuesBySymbol[symbol][dateString]); + } + + totalInvestmentValues[dateString] = + totalInvestmentValues[dateString] ?? new Big(0); + + if (investmentValuesBySymbol[symbol]?.[dateString]) { + totalInvestmentValues[dateString] = totalInvestmentValues[ + dateString + ].add(investmentValuesBySymbol[symbol][dateString]); + } + } + } + + const isInPercentage = true; + + return Object.keys(totalNetPerformanceValues).map((date) => { + return isInPercentage + ? { + date, + value: totalInvestmentValues[date].eq(0) + ? 0 + : totalNetPerformanceValues[date] + .div(totalInvestmentValues[date]) + .mul(100) + .toNumber() + } + : { + date, + value: totalNetPerformanceValues[date].toNumber() + }; + }); + } + public async getCurrentPositions( start: Date, end = new Date(Date.now()) @@ -710,15 +834,19 @@ export class PortfolioCalculator { private getSymbolMetrics({ end, + isChartMode = false, marketSymbolMap, start, + step = 1, symbol }: { end: Date; + isChartMode?: boolean; marketSymbolMap: { [date: string]: { [symbol: string]: Big }; }; start: Date; + step?: number; symbol: string; }) { let orders: PortfolioOrderItem[] = this.orders.filter((order) => { @@ -767,10 +895,12 @@ export class PortfolioCalculator { let grossPerformanceFromSells = new Big(0); let initialValue: Big; let investmentAtStartDate: Big; + const investmentValues: { [date: string]: Big } = {}; let lastAveragePrice = new Big(0); let lastTransactionInvestment = new Big(0); let lastValueOfInvestmentBeforeTransaction = new Big(0); let maxTotalInvestment = new Big(0); + const netPerformanceValues: { [date: string]: Big } = {}; let timeWeightedGrossPerformancePercentage = new Big(1); let timeWeightedNetPerformancePercentage = new Big(1); let totalInvestment = new Big(0); @@ -805,6 +935,41 @@ export class PortfolioCalculator { unitPrice: unitPriceAtEndDate }); + let day = start; + let lastUnitPrice: Big; + + if (isChartMode) { + const datesWithOrders = {}; + + for (const order of orders) { + datesWithOrders[order.date] = true; + } + + while (isBefore(day, end)) { + const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; + + if (!hasDate) { + orders.push({ + symbol, + currency: null, + date: format(day, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: + marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? + lastUnitPrice + }); + } + + lastUnitPrice = last(orders).unitPrice; + + day = addDays(day, step); + } + } + // Sort orders so that the start and end placeholder order are at the right // position orders = sortBy(orders, (order) => { @@ -968,6 +1133,14 @@ export class PortfolioCalculator { grossPerformanceAtStartDate = grossPerformance; } + if (isChartMode && i > indexOfStartOrder) { + netPerformanceValues[order.date] = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + investmentValues[order.date] = totalInvestment; + } + if (i === indexOfEndOrder) { break; } @@ -1056,7 +1229,9 @@ export class PortfolioCalculator { return { initialValue, grossPerformancePercentage, + investmentValues, netPerformancePercentage, + netPerformanceValues, hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), netPerformance: totalNetPerformance, grossPerformance: totalGrossPerformance diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8868245c4..737d7db01 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -57,7 +57,6 @@ import { } from '@prisma/client'; import Big from 'big.js'; import { - addDays, differenceInDays, endOfToday, format, @@ -72,7 +71,7 @@ import { subDays, subYears } from 'date-fns'; -import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; +import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; import { HistoricalDataContainer, @@ -391,40 +390,16 @@ export class PortfolioService { daysInMarket / Math.min(daysInMarket, PortfolioService.MAX_CHART_ITEMS) ); - const items: HistoricalDataItem[] = []; - - let currentEndDate = startDate; - - while (isBefore(currentEndDate, endDate)) { - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate, - currentEndDate - ); - - items.push({ - date: format(currentEndDate, DATE_FORMAT), - value: currentPositions.netPerformancePercentage.toNumber() * 100 - }); - - currentEndDate = addDays(currentEndDate, step); - } - - const today = new Date(); - - if (last(items)?.date !== format(today, DATE_FORMAT)) { - // Add today - const { netPerformancePercentage } = - await portfolioCalculator.getCurrentPositions(startDate, today); - items.push({ - date: format(today, DATE_FORMAT), - value: netPerformancePercentage.toNumber() * 100 - }); - } + const items = await portfolioCalculator.getChartData( + startDate, + endDate, + step + ); return { + items, isAllTimeHigh: false, - isAllTimeLow: false, - items: items + isAllTimeLow: false }; }