From 67f2b326f3c6efd3455613da67611e3a757ecc68 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 9 Apr 2022 10:17:31 +0200 Subject: [PATCH] Switch to new calculation engine (#814) * Switch to new calculation engine * Clean up old portfolio calculation engine (#815) * Rename new portfolio calculation engine (#816) * Update changelog --- CHANGELOG.md | 1 + .../api/src/app/account/account.controller.ts | 11 +- ...olio-calculator-baln-buy-and-sell.spec.ts} | 10 +- ... => portfolio-calculator-baln-buy.spec.ts} | 10 +- .../portfolio-calculator-new.spec.ts | 73 - .../app/portfolio/portfolio-calculator-new.ts | 997 ------ ...=> portfolio-calculator-no-orders.spec.ts} | 10 +- .../portfolio/portfolio-calculator.spec.ts | 2970 +---------------- .../src/app/portfolio/portfolio-calculator.ts | 589 +++- .../portfolio/portfolio-service.strategy.ts | 26 - .../src/app/portfolio/portfolio.controller.ts | 60 +- .../api/src/app/portfolio/portfolio.module.ts | 6 +- .../app/portfolio/portfolio.service-new.ts | 1324 -------- .../src/app/portfolio/portfolio.service.ts | 213 +- .../interfaces/user-settings.interface.ts | 1 - .../src/app/user/update-user-setting.dto.ts | 4 - .../pages/account/account-page.component.ts | 18 - .../src/app/pages/account/account-page.html | 17 - 18 files changed, 644 insertions(+), 5696 deletions(-) rename apps/api/src/app/portfolio/{portfolio-calculator-new-baln-buy-and-sell.spec.ts => portfolio-calculator-baln-buy-and-sell.spec.ts} (89%) rename apps/api/src/app/portfolio/{portfolio-calculator-new-baln-buy.spec.ts => portfolio-calculator-baln-buy.spec.ts} (88%) delete mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts delete mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new.ts rename apps/api/src/app/portfolio/{portfolio-calculator-new-no-orders.spec.ts => portfolio-calculator-no-orders.spec.ts} (81%) delete mode 100644 apps/api/src/app/portfolio/portfolio-service.strategy.ts delete mode 100644 apps/api/src/app/portfolio/portfolio.service-new.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a59abcf3..4a2fb77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Switched to the new calculation engine - Improved the 4% rule in the _FIRE_ section ## 1.133.0 - 07.04.2022 diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 64530c37..819fc5a0 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,4 +1,4 @@ -import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; 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 portfolioServiceStrategy: PortfolioServiceStrategy, + private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -91,9 +91,10 @@ export class AccountController { this.request.user.id ); - let accountsWithAggregations = await this.portfolioServiceStrategy - .get() - .getAccountsWithAggregations(impersonationUserId || this.request.user.id); + let accountsWithAggregations = + await this.portfolioService.getAccountsWithAggregations( + impersonationUserId || this.request.user.id + ); if ( impersonationUserId || diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts similarity index 89% rename from apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts rename to apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts index 5dddc53f..ea35cdd7 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculatorNew } from './portfolio-calculator-new'; +import { PortfolioCalculator } from './portfolio-calculator'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); -describe('PortfolioCalculatorNew', () => { +describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; beforeEach(() => { @@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => { describe('get current positions', () => { it.only('with BALN.SW buy and sell', async () => { - const portfolioCalculatorNew = new PortfolioCalculatorNew({ + const portfolioCalculator = new PortfolioCalculator({ currentRateService, currency: 'CHF', orders: [ @@ -52,13 +52,13 @@ describe('PortfolioCalculatorNew', () => { ] }); - portfolioCalculatorNew.computeTransactionPoints(); + portfolioCalculator.computeTransactionPoints(); const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); - const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + const currentPositions = await portfolioCalculator.getCurrentPositions( parseDate('2021-11-22') ); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts similarity index 88% rename from apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts rename to apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts index de0f1f0b..a6fe1af4 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts @@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculatorNew } from './portfolio-calculator-new'; +import { PortfolioCalculator } from './portfolio-calculator'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); -describe('PortfolioCalculatorNew', () => { +describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; beforeEach(() => { @@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => { describe('get current positions', () => { it.only('with BALN.SW buy', async () => { - const portfolioCalculatorNew = new PortfolioCalculatorNew({ + const portfolioCalculator = new PortfolioCalculator({ currentRateService, currency: 'CHF', orders: [ @@ -41,13 +41,13 @@ describe('PortfolioCalculatorNew', () => { ] }); - portfolioCalculatorNew.computeTransactionPoints(); + portfolioCalculator.computeTransactionPoints(); const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); - const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + const currentPositions = await portfolioCalculator.getCurrentPositions( parseDate('2021-11-30') ); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts deleted file mode 100644 index 72e3091f..00000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import Big from 'big.js'; - -import { CurrentRateService } from './current-rate.service'; -import { PortfolioCalculatorNew } from './portfolio-calculator-new'; - -describe('PortfolioCalculatorNew', () => { - let currentRateService: CurrentRateService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); - }); - - describe('annualized performance percentage', () => { - const portfolioCalculatorNew = new PortfolioCalculatorNew({ - currentRateService, - currency: 'USD', - orders: [] - }); - - it('Get annualized performance', async () => { - expect( - portfolioCalculatorNew - .getAnnualizedPerformancePercent({ - daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day - netPerformancePercent: new Big(0) - }) - .toNumber() - ).toEqual(0); - - expect( - portfolioCalculatorNew - .getAnnualizedPerformancePercent({ - daysInMarket: 0, - netPerformancePercent: new Big(0) - }) - .toNumber() - ).toEqual(0); - - /** - * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html - */ - expect( - portfolioCalculatorNew - .getAnnualizedPerformancePercent({ - daysInMarket: 65, // < 1 year - netPerformancePercent: new Big(0.1025) - }) - .toNumber() - ).toBeCloseTo(0.729705); - - expect( - portfolioCalculatorNew - .getAnnualizedPerformancePercent({ - daysInMarket: 365, // 1 year - netPerformancePercent: new Big(0.05) - }) - .toNumber() - ).toBeCloseTo(0.05); - - /** - * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation - */ - expect( - portfolioCalculatorNew - .getAnnualizedPerformancePercent({ - daysInMarket: 575, // > 1 year - netPerformancePercent: new Big(0.2374) - }) - .toNumber() - ).toBeCloseTo(0.145); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new.ts b/apps/api/src/app/portfolio/portfolio-calculator-new.ts deleted file mode 100644 index 3b8d30cf..00000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-new.ts +++ /dev/null @@ -1,997 +0,0 @@ -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 { - ResponseError, - TimelinePosition, - UniqueAsset -} 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, - 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 static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT = - true; - - private static readonly ENABLE_LOGGING = false; - - 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) - .plus(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) { - const exponent = new Big(365).div(daysInMarket).toNumber(); - return new Big( - Math.pow(netPerformancePercent.plus(1).toNumber(), exponent) - ).minus(1); - } - - 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), - 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 hasAnySymbolMetricsErrors = false; - - const errors: ResponseError['errors'] = []; - - for (const item of lastTransactionPoint.items) { - const marketValue = marketSymbolMap[todayString]?.[item.symbol]; - - const { - grossPerformance, - grossPerformancePercentage, - hasErrors, - initialValue, - netPerformance, - netPerformancePercentage - } = this.getSymbolMetrics({ - marketSymbolMap, - start, - symbol: item.symbol - }); - - hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || 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 - }); - - if (hasErrors) { - errors.push({ dataSource: item.dataSource, symbol: item.symbol }); - } - } - - const overall = this.calculateOverallPerformance(positions, initialValues); - - return { - ...overall, - errors, - positions, - hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors - }; - } - - 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.plus(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: { [symbol: string]: Big } - ) { - let currentValue = new Big(0); - let grossPerformance = new Big(0); - let grossPerformancePercentage = new Big(0); - let hasErrors = false; - let netPerformance = new Big(0); - let netPerformancePercentage = new Big(0); - let sumOfWeights = new Big(0); - let totalInvestment = new Big(0); - - for (const currentPosition of positions) { - if (currentPosition.marketPrice) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPrice).mul(currentPosition.quantity) - ); - } else { - hasErrors = true; - } - - totalInvestment = totalInvestment.plus(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) { - // Use the average from the initial value and the current investment as - // a weight - const weight = (initialValues[currentPosition.symbol] ?? new Big(0)) - .plus(currentPosition.investment) - .div(2); - - sumOfWeights = sumOfWeights.plus(weight); - - grossPerformancePercentage = grossPerformancePercentage.plus( - currentPosition.grossPerformancePercentage.mul(weight) - ); - - netPerformancePercentage = netPerformancePercentage.plus( - currentPosition.netPerformancePercentage.mul(weight) - ); - } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`, - 'PortfolioCalculatorNew' - ); - hasErrors = true; - } - } - - if (sumOfWeights.gt(0)) { - grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights); - netPerformancePercentage = netPerformancePercentage.div(sumOfWeights); - } else { - grossPerformancePercentage = new Big(0); - netPerformancePercentage = new Big(0); - } - - return { - currentValue, - grossPerformance, - grossPerformancePercentage, - hasErrors, - 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.plus(item.investment); - fees = fees.plus(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, - 'PortfolioCalculatorNew' - ); - 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.plus( - 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 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 averagePriceAtEndDate = new Big(0); - let averagePriceAtStartDate = 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 investmentAtStartDate: Big; - let lastAveragePrice = new Big(0); - let lastTransactionInvestment = new Big(0); - let lastValueOfInvestmentBeforeTransaction = new Big(0); - let maxTotalInvestment = new Big(0); - let timeWeightedGrossPerformancePercentage = new Big(1); - let timeWeightedNetPerformancePercentage = new Big(1); - let totalInvestment = new Big(0); - let totalInvestmentWithGrossPerformanceFromSell = new Big(0); - let totalUnits = new Big(0); - let valueAtStartDate: Big; - - // 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 - }); - - 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 - }); - - // 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'; - }); - - const indexOfEndOrder = orders.findIndex((order) => { - return order.itemType === 'end'; - }); - - for (let i = 0; i < orders.length; i += 1) { - const order = orders[i]; - - if (order.itemType === 'start') { - // Take the unit price of the order as the market price if there are no - // orders of this symbol before the start date - order.unitPrice = - indexOfStartOrder === 0 - ? orders[i + 1]?.unitPrice - : unitPriceAtStartDate; - } - - // Calculate the average start price as soon as any units are held - if ( - averagePriceAtStartDate.eq(0) && - i >= indexOfStartOrder && - totalUnits.gt(0) - ) { - averagePriceAtStartDate = totalInvestment.div(totalUnits); - } - - const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPrice - ); - - if (!investmentAtStartDate && i >= indexOfStartOrder) { - investmentAtStartDate = totalInvestment ?? new Big(0); - valueAtStartDate = valueOfInvestmentBeforeTransaction; - } - - const transactionInvestment = order.quantity - .mul(order.unitPrice) - .mul(this.getFactor(order.type)); - - totalInvestment = totalInvestment.plus(transactionInvestment); - - if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { - maxTotalInvestment = totalInvestment; - } - - if (i === indexOfEndOrder && totalUnits.gt(0)) { - averagePriceAtEndDate = totalInvestment.div(totalUnits); - } - - if (i >= indexOfStartOrder && !initialValue) { - if ( - i === indexOfStartOrder && - !valueOfInvestmentBeforeTransaction.eq(0) - ) { - initialValue = valueOfInvestmentBeforeTransaction; - } else if (transactionInvestment.gt(0)) { - initialValue = transactionInvestment; - } - } - - fees = fees.plus(order.fee); - - totalUnits = totalUnits.plus( - order.quantity.mul(this.getFactor(order.type)) - ); - - const valueOfInvestment = totalUnits.mul(order.unitPrice); - - const grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) - : new Big(0); - - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell - ); - - totalInvestmentWithGrossPerformanceFromSell = - totalInvestmentWithGrossPerformanceFromSell - .plus(transactionInvestment) - .plus(grossPerformanceFromSell); - - lastAveragePrice = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); - - const newGrossPerformance = valueOfInvestment - .minus(totalInvestmentWithGrossPerformanceFromSell) - .plus(grossPerformanceFromSells); - - if ( - i > indexOfStartOrder && - !lastValueOfInvestmentBeforeTransaction - .plus(lastTransactionInvestment) - .eq(0) - ) { - const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - .minus( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ) - .div( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ); - - timeWeightedGrossPerformancePercentage = - timeWeightedGrossPerformancePercentage.mul( - new Big(1).plus(grossHoldingPeriodReturn) - ); - - const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - .minus(fees.minus(feesAtStartDate)) - .minus( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ) - .div( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ); - - timeWeightedNetPerformancePercentage = - timeWeightedNetPerformancePercentage.mul( - new Big(1).plus(netHoldingPeriodReturn) - ); - } - - grossPerformance = newGrossPerformance; - - lastTransactionInvestment = transactionInvestment; - - lastValueOfInvestmentBeforeTransaction = - valueOfInvestmentBeforeTransaction; - - if (order.itemType === 'start') { - feesAtStartDate = fees; - grossPerformanceAtStartDate = grossPerformance; - } - } - - timeWeightedGrossPerformancePercentage = - timeWeightedGrossPerformancePercentage.minus(1); - - timeWeightedNetPerformancePercentage = - timeWeightedNetPerformancePercentage.minus(1); - - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalNetPerformance = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - - const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( - maxTotalInvestment.minus(investmentAtStartDate) - ); - - const grossPerformancePercentage = - PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || - averagePriceAtStartDate.eq(0) || - averagePriceAtEndDate.eq(0) || - orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); - - const feesPerUnit = totalUnits.gt(0) - ? fees.minus(feesAtStartDate).div(totalUnits) - : new Big(0); - - const netPerformancePercentage = - PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || - averagePriceAtStartDate.eq(0) || - averagePriceAtEndDate.eq(0) || - orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .minus(feesPerUnit) - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); - - if (PortfolioCalculatorNew.ENABLE_LOGGING) { - console.log( - ` - ${symbol} - Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( - 2 - )} -> ${unitPriceAtEndDate.toFixed(2)} - Average price: ${averagePriceAtStartDate.toFixed( - 2 - )} -> ${averagePriceAtEndDate.toFixed(2)} - Max. total investment: ${maxTotalInvestment.toFixed(2)} - Gross performance: ${totalGrossPerformance.toFixed( - 2 - )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% - Fees per unit: ${feesPerUnit.toFixed(2)} - Net performance: ${totalNetPerformance.toFixed( - 2 - )} / ${netPerformancePercentage.mul(100).toFixed(2)}%` - ); - } - - return { - initialValue, - grossPerformancePercentage, - netPerformancePercentage, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance, - grossPerformance: totalGrossPerformance - }; - } - - 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-calculator-new-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts similarity index 81% rename from apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts rename to apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts index 41e2ca38..18d6cb34 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts @@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculatorNew } from './portfolio-calculator-new'; +import { PortfolioCalculator } from './portfolio-calculator'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); -describe('PortfolioCalculatorNew', () => { +describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; beforeEach(() => { @@ -23,19 +23,19 @@ describe('PortfolioCalculatorNew', () => { describe('get current positions', () => { it('with no orders', async () => { - const portfolioCalculatorNew = new PortfolioCalculatorNew({ + const portfolioCalculator = new PortfolioCalculator({ currentRateService, currency: 'CHF', orders: [] }); - portfolioCalculatorNew.computeTransactionPoints(); + portfolioCalculator.computeTransactionPoints(); const spy = jest .spyOn(Date, 'now') .mockImplementation(() => parseDate('2021-12-18').getTime()); - const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + const currentPositions = await portfolioCalculator.getCurrentPositions( new Date() ); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts index 74c45f02..23f0a8a8 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts @@ -1,308 +1,8 @@ -import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; -import { DataSource } from '@prisma/client'; import Big from 'big.js'; -import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns'; import { CurrentRateService } from './current-rate.service'; -import { GetValuesParams } from './interfaces/get-values-params.interface'; -import { PortfolioOrder } from './interfaces/portfolio-order.interface'; -import { TimelinePeriod } from './interfaces/timeline-period.interface'; -import { TimelineSpecification } from './interfaces/timeline-specification.interface'; -import { TransactionPoint } from './interfaces/transaction-point.interface'; import { PortfolioCalculator } from './portfolio-calculator'; -function mockGetValue(symbol: string, date: Date) { - switch (symbol) { - case 'AMZN': - return { marketPrice: 2021.99 }; - case 'BALN.SW': - if (isSameDay(parseDate('2021-11-12'), date)) { - return { marketPrice: 146 }; - } else if (isSameDay(parseDate('2021-11-22'), date)) { - return { marketPrice: 142.9 }; - } else if (isSameDay(parseDate('2021-11-26'), date)) { - return { marketPrice: 139.9 }; - } else if (isSameDay(parseDate('2021-11-30'), date)) { - return { marketPrice: 136.6 }; - } else if (isSameDay(parseDate('2021-12-18'), date)) { - return { marketPrice: 143.9 }; - } - - return { marketPrice: 0 }; - case 'MFA': - if (isSameDay(parseDate('2010-12-31'), date)) { - return { marketPrice: 1 }; - } else if (isSameDay(parseDate('2011-08-15'), date)) { - return { marketPrice: 1.162484 }; // 1162484 / 1000000 - } else if (isSameDay(parseDate('2011-12-31'), date)) { - return { marketPrice: 1.097884981 }; // 1192328 / 1086022.689344541 - } - - return { marketPrice: 0 }; - case 'SPA': - if (isSameDay(parseDate('2013-12-31'), date)) { - return { marketPrice: 1.025 }; // 205 / 200 - } - - return { marketPrice: 0 }; - case 'SPB': - if (isSameDay(parseDate('2013-12-31'), date)) { - return { marketPrice: 1.04 }; // 312 / 300 - } - - return { marketPrice: 0 }; - case 'TSLA': - if (isSameDay(parseDate('2021-01-02'), date)) { - return { marketPrice: 666.66 }; - } else if (isSameDay(parseDate('2021-07-26'), date)) { - return { marketPrice: 657.62 }; - } - - return { marketPrice: 0 }; - case 'VTI': - switch (format(date, DATE_FORMAT)) { - case '2019-01-01': - return { marketPrice: 144.38 }; - case '2019-02-01': - return { marketPrice: 144.38 }; - case '2019-03-01': - return { marketPrice: 146.62 }; - case '2019-04-01': - return { marketPrice: 149.1 }; - case '2019-05-01': - return { marketPrice: 151.5 }; - case '2019-06-01': - return { marketPrice: 153.98 }; - case '2019-07-01': - return { marketPrice: 156.38 }; - case '2019-08-01': - return { marketPrice: 158.86 }; - case '2019-08-03': - return { marketPrice: 159.02 }; - case '2019-09-01': - return { marketPrice: 161.34 }; - case '2019-10-01': - return { marketPrice: 163.74 }; - case '2019-11-01': - return { marketPrice: 166.22 }; - case '2019-12-01': - return { marketPrice: 168.62 }; - case '2020-01-01': - return { marketPrice: 171.1 }; - case '2020-02-01': - return { marketPrice: 173.58 }; - case '2020-02-02': - return { marketPrice: 173.66 }; - case '2020-03-01': - return { marketPrice: 175.9 }; - case '2020-04-01': - return { marketPrice: 178.38 }; - case '2020-05-01': - return { marketPrice: 180.78 }; - case '2020-06-01': - return { marketPrice: 183.26 }; - case '2020-07-01': - return { marketPrice: 185.66 }; - case '2020-08-01': - return { marketPrice: 188.14 }; - case '2020-08-02': - return { marketPrice: 188.22 }; - case '2020-08-03': - return { marketPrice: 188.3 }; - case '2020-09-01': - return { marketPrice: 190.62 }; - case '2020-10-01': - return { marketPrice: 193.02 }; - case '2020-11-01': - return { marketPrice: 195.5 }; - case '2020-12-01': - return { marketPrice: 197.9 }; - case '2021-01-01': - return { marketPrice: 200.38 }; - case '2021-02-01': - return { marketPrice: 202.86 }; - case '2021-03-01': - return { marketPrice: 205.1 }; - case '2021-04-01': - return { marketPrice: 207.58 }; - case '2021-05-01': - return { marketPrice: 209.98 }; - case '2021-06-01': - return { marketPrice: 212.46 }; - case '2021-06-02': - return { marketPrice: 212.54 }; - case '2021-06-03': - return { marketPrice: 212.62 }; - case '2021-06-04': - return { marketPrice: 212.7 }; - case '2021-06-05': - return { marketPrice: 212.78 }; - case '2021-06-06': - return { marketPrice: 212.86 }; - case '2021-06-07': - return { marketPrice: 212.94 }; - case '2021-06-08': - return { marketPrice: 213.02 }; - case '2021-06-09': - return { marketPrice: 213.1 }; - case '2021-06-10': - return { marketPrice: 213.18 }; - case '2021-06-11': - return { marketPrice: 213.26 }; - case '2021-06-12': - return { marketPrice: 213.34 }; - case '2021-06-13': - return { marketPrice: 213.42 }; - case '2021-06-14': - return { marketPrice: 213.5 }; - case '2021-06-15': - return { marketPrice: 213.58 }; - case '2021-06-16': - return { marketPrice: 213.66 }; - case '2021-06-17': - return { marketPrice: 213.74 }; - case '2021-06-18': - return { marketPrice: 213.82 }; - case '2021-06-19': - return { marketPrice: 213.9 }; - case '2021-06-20': - return { marketPrice: 213.98 }; - case '2021-06-21': - return { marketPrice: 214.06 }; - case '2021-06-22': - return { marketPrice: 214.14 }; - case '2021-06-23': - return { marketPrice: 214.22 }; - case '2021-06-24': - return { marketPrice: 214.3 }; - case '2021-06-25': - return { marketPrice: 214.38 }; - case '2021-06-26': - return { marketPrice: 214.46 }; - case '2021-06-27': - return { marketPrice: 214.54 }; - case '2021-06-28': - return { marketPrice: 214.62 }; - case '2021-06-29': - return { marketPrice: 214.7 }; - case '2021-06-30': - return { marketPrice: 214.78 }; - case '2021-07-01': - return { marketPrice: 214.86 }; - case '2021-07-02': - return { marketPrice: 214.94 }; - case '2021-07-03': - return { marketPrice: 215.02 }; - case '2021-07-04': - return { marketPrice: 215.1 }; - case '2021-07-05': - return { marketPrice: 215.18 }; - case '2021-07-06': - return { marketPrice: 215.26 }; - case '2021-07-07': - return { marketPrice: 215.34 }; - case '2021-07-08': - return { marketPrice: 215.42 }; - case '2021-07-09': - return { marketPrice: 215.5 }; - case '2021-07-10': - return { marketPrice: 215.58 }; - case '2021-07-11': - return { marketPrice: 215.66 }; - case '2021-07-12': - return { marketPrice: 215.74 }; - case '2021-07-13': - return { marketPrice: 215.82 }; - case '2021-07-14': - return { marketPrice: 215.9 }; - case '2021-07-15': - return { marketPrice: 215.98 }; - case '2021-07-16': - return { marketPrice: 216.06 }; - case '2021-07-17': - return { marketPrice: 216.14 }; - case '2021-07-18': - return { marketPrice: 216.22 }; - case '2021-07-19': - return { marketPrice: 216.3 }; - case '2021-07-20': - return { marketPrice: 216.38 }; - case '2021-07-21': - return { marketPrice: 216.46 }; - case '2021-07-22': - return { marketPrice: 216.54 }; - case '2021-07-23': - return { marketPrice: 216.62 }; - case '2021-07-24': - return { marketPrice: 216.7 }; - case '2021-07-25': - return { marketPrice: 216.78 }; - case '2021-07-26': - return { marketPrice: 216.86 }; - case '2021-07-27': - return { marketPrice: 216.94 }; - case '2021-07-28': - return { marketPrice: 217.02 }; - case '2021-07-29': - return { marketPrice: 217.1 }; - case '2021-07-30': - return { marketPrice: 217.18 }; - case '2021-07-31': - return { marketPrice: 217.26 }; - case '2021-08-01': - return { marketPrice: 217.34 }; - case '2020-10-24': - return { marketPrice: 194.86 }; - default: - return { marketPrice: 0 }; - } - - default: - return { marketPrice: 0 }; - } -} - -jest.mock('./current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return { - getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => { - const result = []; - if (dateQuery.lt) { - for ( - let date = resetHours(dateQuery.gte); - isBefore(date, endOfDay(dateQuery.lt)); - date = addDays(date, 1) - ) { - for (const dataGatheringItem of dataGatheringItems) { - result.push({ - date, - marketPrice: mockGetValue(dataGatheringItem.symbol, date) - .marketPrice, - symbol: dataGatheringItem.symbol - }); - } - } - } else { - for (const date of dateQuery.in) { - for (const dataGatheringItem of dataGatheringItems) { - result.push({ - date, - marketPrice: mockGetValue(dataGatheringItem.symbol, date) - .marketPrice, - symbol: dataGatheringItem.symbol - }); - } - } - } - return Promise.resolve(result); - } - }; - }) - }; -}); - describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; @@ -310,2326 +10,12 @@ describe('PortfolioCalculator', () => { currentRateService = new CurrentRateService(null, null, null); }); - describe('calculate transaction points', () => { - it('with orders of only one symbol', () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.computeTransactionPoints(ordersVTI); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual( - ordersVTITransactionPoints - ); - }); - - it('with orders of only one symbol and a fee', () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const orders: PortfolioOrder[] = [ - { - date: '2019-02-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('144.38'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('5') - }, - { - date: '2019-08-03', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('147.99'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('10') - }, - { - date: '2020-02-02', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('15'), - symbol: 'VTI', - type: 'SELL', - unitPrice: new Big('151.41'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('5') - } - ]; - portfolioCalculator.computeTransactionPoints(orders); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2019-02-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 1, - fee: new Big('5') - } - ] - }, - { - date: '2019-08-03', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 2, - fee: new Big('15') - } - ] - }, - { - date: '2020-02-02', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 3, - fee: new Big('20') - } - ] - } - ]); - }); - - it('with orders of two different symbols and a fee', () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const orders: PortfolioOrder[] = [ - { - date: '2019-02-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('144.38'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('5') - }, - { - date: '2019-08-03', - name: 'Something else', - quantity: new Big('10'), - symbol: 'VTX', - type: 'BUY', - unitPrice: new Big('147.99'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('10') - }, - { - date: '2020-02-02', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('5'), - symbol: 'VTI', - type: 'SELL', - unitPrice: new Big('151.41'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big('5') - } - ]; - portfolioCalculator.computeTransactionPoints(orders); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2019-02-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 1, - fee: new Big('5') - } - ] - }, - { - date: '2019-08-03', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 1, - fee: new Big('5') - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTX', - investment: new Big('1479.9'), - currency: 'USD', - firstBuyDate: '2019-08-03', - transactionCount: 1, - fee: new Big('10') - } - ] - }, - { - date: '2020-02-02', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('686.75'), - currency: 'USD', - firstBuyDate: '2019-02-01', - transactionCount: 2, - fee: new Big('10') - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTX', - investment: new Big('1479.9'), - currency: 'USD', - firstBuyDate: '2019-08-03', - transactionCount: 1, - fee: new Big('10') - } - ] - } - ]); - }); - - it('with two orders at the same day of the same type', () => { - const orders: PortfolioOrder[] = [ - ...ordersVTI, - { - currency: 'USD', - dataSource: DataSource.YAHOO, - date: '2021-02-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('20'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('197.15'), - fee: new Big(0) - } - ]; - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.computeTransactionPoints(orders); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2019-02-01', - items: [ - { - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - investment: new Big('1443.8'), - quantity: new Big('10'), - symbol: 'VTI', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - investment: new Big('2923.7'), - quantity: new Big('20'), - symbol: 'VTI', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - investment: new Big('652.55'), - quantity: new Big('5'), - symbol: 'VTI', - fee: new Big(0), - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - investment: new Big('6627.05'), - quantity: new Big('35'), - symbol: 'VTI', - fee: new Big(0), - transactionCount: 5 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - investment: new Big('8403.95'), - quantity: new Big('45'), - symbol: 'VTI', - fee: new Big(0), - transactionCount: 6 - } - ] - } - ]); - }); - - it('with additional order', () => { - const orders: PortfolioOrder[] = [ - ...ordersVTI, - { - currency: 'USD', - dataSource: DataSource.YAHOO, - date: '2019-09-01', - name: 'Amazon.com, Inc.', - quantity: new Big('5'), - symbol: 'AMZN', - type: 'BUY', - unitPrice: new Big('2021.99'), - fee: new Big(0) - } - ]; - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.computeTransactionPoints(orders); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2019-02-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2019-09-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('15'), - symbol: 'VTI', - investment: new Big('2684.05'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 4 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('25'), - symbol: 'VTI', - investment: new Big('4460.95'), - currency: 'USD', - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 5 - } - ] - } - ]); - }); - - it('with additional buy & sell', () => { - const orders: PortfolioOrder[] = [ - ...ordersVTI, - { - date: '2019-09-01', - name: 'Amazon.com, Inc.', - quantity: new Big('5'), - symbol: 'AMZN', - type: 'BUY', - unitPrice: new Big('2021.99'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2020-08-02', - name: 'Amazon.com, Inc.', - quantity: new Big('5'), - symbol: 'AMZN', - type: 'SELL', - unitPrice: new Big('2412.23'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - } - ]; - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.computeTransactionPoints(orders); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual( - transactionPointsBuyAndSell - ); - }); - - it('with mixed symbols', () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.computeTransactionPoints(ordersMixedSymbols); - const portfolioItemsAtTransactionPoints = - portfolioCalculator.getTransactionPoints(); - - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2017-01-03', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('50'), - symbol: 'TSLA', - investment: new Big('2148.5'), - currency: 'USD', - firstBuyDate: '2017-01-03', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2017-07-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('0.5614682'), - symbol: 'BTCUSD', - investment: new Big('1999.9999999999998659756'), - currency: 'USD', - firstBuyDate: '2017-07-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('50'), - symbol: 'TSLA', - investment: new Big('2148.5'), - currency: 'USD', - firstBuyDate: '2017-01-03', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2018-09-01', - items: [ - { - dataSource: DataSource.YAHOO, - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - firstBuyDate: '2018-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('0.5614682'), - symbol: 'BTCUSD', - investment: new Big('1999.9999999999998659756'), - currency: 'USD', - firstBuyDate: '2017-07-01', - fee: new Big(0), - transactionCount: 1 - }, - { - dataSource: DataSource.YAHOO, - quantity: new Big('50'), - symbol: 'TSLA', - investment: new Big('2148.5'), - currency: 'USD', - firstBuyDate: '2017-01-03', - fee: new Big(0), - transactionCount: 1 - } - ] - } - ]); - }); - }); - - describe('get current positions', () => { - it('with single TSLA and early start', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2020-01-21') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('657.62'), - grossPerformance: new Big('-61.84'), - grossPerformancePercentage: new Big('-0.08595335390431712673'), - totalInvestment: new Big('719.46'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('719.46'), - currency: 'USD', - firstBuyDate: '2021-01-01', - grossPerformance: new Big('-61.84'), // 657.62-719.46=-61.84 - grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673 - investment: new Big('719.46'), - marketPrice: 657.62, - quantity: new Big('1'), - symbol: 'TSLA', - transactionCount: 1 - }) - ] - }) - ); - }); - - it('with single TSLA and buy day start', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2021-01-01') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('657.62'), - grossPerformance: new Big('-61.84'), - grossPerformancePercentage: new Big('-0.08595335390431712673'), - totalInvestment: new Big('719.46'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('719.46'), - currency: 'USD', - firstBuyDate: '2021-01-01', - grossPerformance: new Big('-61.84'), // 657.62-719.46=-61.84 - grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673 - investment: new Big('719.46'), - marketPrice: 657.62, - quantity: new Big('1'), - symbol: 'TSLA', - transactionCount: 1 - }) - ] - }) - ); - }); - - it('with single TSLA and late start', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2021-01-02') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('657.62'), - grossPerformance: new Big('-9.04'), - grossPerformancePercentage: new Big('-0.01356013560135601356'), - totalInvestment: new Big('719.46'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('719.46'), - currency: 'USD', - firstBuyDate: '2021-01-01', - grossPerformance: new Big('-9.04'), // 657.62-666.66=-9.04 - grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136 - investment: new Big('719.46'), - marketPrice: 657.62, - quantity: new Big('1'), - symbol: 'TSLA', - transactionCount: 1 - }) - ] - }) - ); - }); - - it('with VTI only', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2019-01-01') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('4871.5'), - grossPerformance: new Big('240.4'), - grossPerformancePercentage: new Big('0.08839407904876477102'), - totalInvestment: new Big('4460.95'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('178.438'), - currency: 'USD', - firstBuyDate: '2019-02-01', - // see next test for details about how to calculate this - grossPerformance: new Big('240.4'), - grossPerformancePercentage: new Big( - '0.0883940790487647710162214425767848424215253864940558186258745429269647266073266478435285352186572448' - ), - investment: new Big('4460.95'), - marketPrice: 194.86, - quantity: new Big('25'), - symbol: 'VTI', - transactionCount: 5 - }) - ] - }) - ); - }); - - it('with buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(transactionPointsBuyAndSell); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2019-01-01') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('4871.5'), - grossPerformance: new Big('240.4'), - grossPerformancePercentage: new Big('0.01104605615757711361'), - totalInvestment: new Big('4460.95'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('0'), - currency: 'USD', - firstBuyDate: '2019-09-01', - grossPerformance: new Big('0'), - grossPerformancePercentage: new Big('0'), - investment: new Big('0'), - marketPrice: 2021.99, - quantity: new Big('0'), - symbol: 'AMZN', - transactionCount: 2 - }), - expect.objectContaining({ - averagePrice: new Big('178.438'), - currency: 'USD', - firstBuyDate: '2019-02-01', - grossPerformance: new Big('240.4'), - grossPerformancePercentage: new Big( - '0.08839407904876477101219019935616297754969945667391763908415656216989674494965785538864363782688167989866968512455219637257546280462751601552' - ), - investment: new Big('4460.95'), - marketPrice: 194.86, - quantity: new Big('25'), - symbol: 'VTI', - transactionCount: 5 - }) - ] - }) - ); - }); - - it('with buy, sell, buy', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints([ - { - date: '2019-09-01', - items: [ - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('805.9'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2020-08-02', - items: [ - { - quantity: new Big('0'), - symbol: 'VTI', - investment: new Big('0'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('1013.9'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 3 - } - ] - } - ]); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2021, 7, 1)).getTime()); // 2021-08-01 - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2019-02-01') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('1086.7'), - grossPerformance: new Big('207.6'), - grossPerformancePercentage: new Big('0.2516103956224511062'), - totalInvestment: new Big('1013.9'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('202.78'), - currency: 'USD', - firstBuyDate: '2019-09-01', - grossPerformance: new Big('207.6'), - grossPerformancePercentage: new Big( - '0.2516103956224511061954915466429950404846' - ), - investment: new Big('1013.9'), - marketPrice: 217.34, - quantity: new Big('5'), - symbol: 'VTI', - transactionCount: 3 - }) - ] - }) - ); - }); - - it('with performance since Jan 1st, 2020', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const transactionPoints: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2020-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - } - ]; - - portfolioCalculator.setTransactionPoints(transactionPoints); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24 - - // 2020-01-01 -> days 334 => value: VTI: 144.38+334*0.08=171.1 => 10*171.10=1711 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => 1883/1711 = 1.100526008 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766 - // cash flow: 2923.7-1443.8=1479.9 - // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/(1883+1479.9) = 1.158880728 - // gross performance: 1883-1711 + 3897.2-3766 = 303.2 - // gross performance percentage: 1.100526008 * 1.158880728 = 1.275378381 => 27.5378381 % - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2020-01-01') - ); - - spy.mockRestore(); - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('3897.2'), - grossPerformance: new Big('303.2'), - grossPerformancePercentage: new Big('0.27537838148272398344'), - totalInvestment: new Big('2923.7'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('146.185'), - firstBuyDate: '2019-02-01', - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - marketPrice: 194.86, - transactionCount: 2, - grossPerformance: new Big('303.2'), - grossPerformancePercentage: new Big( - '0.2753783814827239834392742298083677500037' - ), - currency: 'USD' - }) - ] - }) - ); - }); - - it('with net performance since Jan 1st, 2020 - include fees', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const transactionPoints: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(50), - transactionCount: 1 - } - ] - }, - { - date: '2020-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(100), - transactionCount: 2 - } - ] - } - ]; - - portfolioCalculator.setTransactionPoints(transactionPoints); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24 - - // 2020-01-01 -> days 334 => value: VTI: 144.38+334*0.08=171.1 => 10*171.10=1711 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => 1883/1711 = 1.100526008 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766 - // cash flow: 2923.7-1443.8=1479.9 - // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/(1883+1479.9) = 1.158880728 - // and net: 3897.2/(1883+1479.9+50) = 1.14190278 - // gross performance: 1883-1711 + 3897.2-3766 = 303.2 - // gross performance percentage: 1.100526008 * 1.158880728 = 1.275378381 => 27.5378381 % - // net performance percentage: 1.100526008 * 1.14190278 = 1.25669371 => 25.669371 % - - // more details: https://github.com/ghostfolio/ghostfolio/issues/324#issuecomment-910530823 - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2020-01-01') - ); - - spy.mockRestore(); - expect(currentPositions).toEqual({ - hasErrors: false, - currentValue: new Big('3897.2'), - grossPerformance: new Big('303.2'), - grossPerformancePercentage: new Big('0.27537838148272398344'), - netAnnualizedPerformance: new Big('0.1412977563032074'), - netPerformance: new Big('253.2'), - netPerformancePercentage: new Big('0.2566937088951485493'), - totalInvestment: new Big('2923.7'), - positions: [ - { - averagePrice: new Big('146.185'), - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - marketPrice: 194.86, - transactionCount: 2, - grossPerformance: new Big('303.2'), - grossPerformancePercentage: new Big( - '0.2753783814827239834392742298083677500037' - ), - netPerformance: new Big('253.2'), // gross - 50 fees - netPerformancePercentage: new Big( - '0.2566937088951485493029975263687800261527' - ), // see details above - currency: 'USD' - } - ] - }); - }); - - it('with net performance since Feb 1st, 2019 - include fees', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const transactionPoints: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(50), - transactionCount: 1 - } - ] - }, - { - date: '2020-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(100), - transactionCount: 2 - } - ] - } - ]; - - portfolioCalculator.setTransactionPoints(transactionPoints); - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24 - - // 2019-02-01 -> value: VTI: 1443.8 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => net: 1883/(1443.8+50) = 1.26054358 - // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766 - // cash flow: 2923.7-1443.8=1479.9 - // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => net: 3897.2/(1883+1479.9+50) = 1.14190278 - // gross performance: 1883-1443.8 + 3897.2-3766 = 570.4 => net performance: 470.4 - // net performance percentage: 1.26054358 * 1.14190278 = 1.43941822 => 43.941822 % - - // more details: https://github.com/ghostfolio/ghostfolio/issues/324#issuecomment-910530823 - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2019-02-01') - ); - - spy.mockRestore(); - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('3897.2'), - netPerformance: new Big('470.4'), - netPerformancePercentage: new Big('0.4394182192526437059'), - totalInvestment: new Big('2923.7'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('146.185'), - firstBuyDate: '2019-02-01', - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - marketPrice: 194.86, - transactionCount: 2, - netPerformance: new Big('470.4'), - netPerformancePercentage: new Big( - '0.4394182192526437058970248283134805555953' - ), // see details above - currency: 'USD' - }) - ] - }) - ); - }); - - /** - * Source: https://www.investopedia.com/terms/t/time-weightedror.asp - */ - it('with TWR example from Investopedia: Scenario 1', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints([ - { - date: '2010-12-31', - items: [ - { - quantity: new Big('1000000'), // 1 million - symbol: 'MFA', // Mutual Fund A - investment: new Big('1000000'), // 1 million - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2010-12-31', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2011-08-15', - items: [ - { - quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484 - symbol: 'MFA', // Mutual Fund A - investment: new Big('1100000'), // 1,000,000 + 100,000 - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2010-12-31', - fee: new Big(0), - transactionCount: 2 - } - ] - } - ]); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2011, 11, 31)).getTime()); // 2011-12-31 - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2010-12-31') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - hasErrors: false, - currentValue: new Big('1192327.999656600298238721'), - grossPerformance: new Big('92327.999656600898394721'), - grossPerformancePercentage: new Big('0.09788498099999947809'), - totalInvestment: new Big('1100000'), - positions: [ - expect.objectContaining({ - averagePrice: new Big('1.01287018290924923237'), // 1'100'000 / 1'086'022.689344542 - firstBuyDate: '2010-12-31', - quantity: new Big('1086022.689344541'), - symbol: 'MFA', - investment: new Big('1100000'), - marketPrice: 1.097884981, - transactionCount: 2, - grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328 - grossPerformancePercentage: new Big( - '0.09788498099999947808927632' - ), // 9.79 % - currency: 'USD' - }) - ] - }) - ); - }); - - /** - * Source: https://www.chsoft.ch/en/assets/Dateien/files/PDF/ePoca/en/Practical%20Performance%20Calculation.pdf - */ - it('with example from chsoft.ch: Performance of a Combination of Investments', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'CHF' - ); - portfolioCalculator.setTransactionPoints([ - { - date: '2012-12-31', - items: [ - { - quantity: new Big('200'), - symbol: 'SPA', // Sub Portfolio A - investment: new Big('200'), - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2012-12-31', - fee: new Big(0), - transactionCount: 1 - }, - { - quantity: new Big('300'), - symbol: 'SPB', // Sub Portfolio B - investment: new Big('300'), - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2012-12-31', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2013-12-31', - items: [ - { - quantity: new Big('200'), - symbol: 'SPA', // Sub Portfolio A - investment: new Big('200'), - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2012-12-31', - fee: new Big(0), - transactionCount: 1 - }, - { - quantity: new Big('300'), - symbol: 'SPB', // Sub Portfolio B - investment: new Big('300'), - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2012-12-31', - fee: new Big(0), - transactionCount: 1 - } - ] - } - ]); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2013, 11, 31)).getTime()); // 2013-12-31 - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2012-12-31') - ); - spy.mockRestore(); - - expect(currentPositions).toEqual( - expect.objectContaining({ - currentValue: new Big('517'), - grossPerformance: new Big('17'), // 517 - 500 - grossPerformancePercentage: new Big('0.034'), // ((200 * 0.025) + (300 * 0.04)) / (200 + 300) = 3.4% - totalInvestment: new Big('500'), - hasErrors: false, - positions: [ - expect.objectContaining({ - averagePrice: new Big('1'), - firstBuyDate: '2012-12-31', - quantity: new Big('200'), - symbol: 'SPA', - investment: new Big('200'), - marketPrice: 1.025, // 205 / 200 - transactionCount: 1, - grossPerformance: new Big('5'), // 205 - 200 - grossPerformancePercentage: new Big('0.025'), - currency: 'CHF' - }), - expect.objectContaining({ - averagePrice: new Big('1'), - firstBuyDate: '2012-12-31', - quantity: new Big('300'), - symbol: 'SPB', - investment: new Big('300'), - marketPrice: 1.04, // 312 / 300 - transactionCount: 1, - grossPerformance: new Big('12'), // 312 - 300 - grossPerformancePercentage: new Big('0.04'), - currency: 'CHF' - }) - ] - }) - ); - }); - - it('with BALN.SW', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'CHF' - ); - - // date,type,ticker,currency,units,price,fee - portfolioCalculator.setTransactionPoints([ - // 12.11.2021,BUY,BALN.SW,CHF,2.00,146.00,1.65 - { - date: '2021-11-12', - items: [ - { - quantity: new Big('2'), - symbol: 'BALN.SW', - investment: new Big('292'), - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2021-11-12', - fee: new Big('1.65'), - transactionCount: 1 - } - ] - }, - // HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow) - // End Value: 142.9 * 2 = 285.8 - // Initial Value: 292 (Investment) - // Cash Flow: 0 - // HWR_n0: (285.8 - 292) / 292 = -0.021232877 - - // 22.11.2021,BUY,BALN.SW,CHF,7.00,142.90,5.75 - { - date: '2021-11-22', - items: [ - { - quantity: new Big('9'), // 7 + 2 - symbol: 'BALN.SW', - investment: new Big('1292.3'), // 142.9 * 7 + 146 * 2 - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2021-11-12', - fee: new Big('7.4'), // 1.65 + 5.75 - transactionCount: 2 - } - ] - }, - // HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow) - // End Value: 139.9 * 9 = 1259.1 - // Initial Value: 285.8 (End Value n0) - // Cash Flow: 1000.3 - // Initial Value + Cash Flow: 285.8 + 1000.3 = 1286.1 - // HWR_n1: (1259.1 - 1286.1) / 1286.1 = -0.020993702 - - // 26.11.2021,BUY,BALN.SW,CHF,3.00,139.90,2.40 - { - date: '2021-11-26', - items: [ - { - quantity: new Big('12'), // 3 + 7 + 2 - symbol: 'BALN.SW', - investment: new Big('1712'), // 139.9 * 3 + 142.9 * 7 + 146 * 2 - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2021-11-12', - fee: new Big('9.8'), // 2.40 + 1.65 + 5.75 - transactionCount: 3 - } - ] - }, - // HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow) - // End Value: 136.6 * 12 = 1639.2 - // Initial Value: 1259.1 (End Value n1) - // Cash Flow: 139.9 * 3 = 419.7 - // Initial Value + Cash Flow: 1259.1 + 419.7 = 1678.8 - // HWR_n2: (1639.2 - 1678.8) / 1678.8 = -0.023588277 - - // 30.11.2021,BUY,BALN.SW,CHF,2.00,136.60,1.55 - { - date: '2021-11-30', - items: [ - { - quantity: new Big('14'), // 2 + 3 + 7 + 2 - symbol: 'BALN.SW', - investment: new Big('1985.2'), // 136.6 * 2 + 139.9 * 3 + 142.9 * 7 + 146 * 2 - currency: 'CHF', - dataSource: DataSource.YAHOO, - firstBuyDate: '2021-11-12', - fee: new Big('11.35'), // 1.55 + 2.40 + 1.65 + 5.75 - transactionCount: 4 - } - ] - } - // HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow) - // End Value: 143.9 * 14 = 2014.6 - // Initial Value: 1639.2 (End Value n2) - // Cash Flow: 136.6 * 2 = 273.2 - // Initial Value + Cash Flow: 1639.2 + 273.2 = 1912.4 - // HWR_n3: (2014.6 - 1912.4) / 1912.4 = 0.053440703 - ]); - - // HWR_total = 1 - (HWR_n0 + 1) * (HWR_n1 + 1) * (HWR_n2 + 1) * (HWR_n3 + 1) - // HWR_total = 1 - (-0.021232877 + 1) * (-0.020993702 + 1) * (-0.023588277 + 1) * (0.053440703 + 1) = 0.014383561 - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2021, 11, 18)).getTime()); // 2021-12-18 - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2021-11-01') - ); - spy.mockRestore(); - - expect(currentPositions).toBeDefined(); - expect(currentPositions.grossPerformance).toEqual(new Big('29.4')); - expect(currentPositions.netPerformance).toEqual(new Big('18.05')); - expect(currentPositions.grossPerformancePercentage).toEqual( - new Big('-0.01438356164383561644') - ); - }); - }); - - describe('calculate timeline', () => { - it('with yearly', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'year' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2020-01-01', - grossPerformance: new Big('498.3'), - netPerformance: new Big('498.3'), - investment: new Big('2923.7'), - value: new Big('3422') // 20 * 171.1 - }, - { - date: '2021-01-01', - grossPerformance: new Big('349.35'), - netPerformance: new Big('349.35'), - investment: new Big('652.55'), - value: new Big('1001.9') // 5 * 200.38 - } - ]); - }); - - it('with yearly and fees', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - const transactionPoints: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(50), - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(100), - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(150), - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - quantity: new Big('15'), - symbol: 'VTI', - investment: new Big('2684.05'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(200), - transactionCount: 4 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - quantity: new Big('25'), - symbol: 'VTI', - investment: new Big('4460.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(250), - transactionCount: 5 - } - ] - } - ]; - portfolioCalculator.setTransactionPoints(transactionPoints); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'year' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2020-01-01', - grossPerformance: new Big('498.3'), - netPerformance: new Big('398.3'), // 100 fees - investment: new Big('2923.7'), - value: new Big('3422') // 20 * 171.1 - }, - { - date: '2021-01-01', - grossPerformance: new Big('349.35'), - netPerformance: new Big('199.35'), // 150 fees - investment: new Big('652.55'), - value: new Big('1001.9') // 5 * 200.38 - } - ]); - }); - - it('with monthly', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'month' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2019-02-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('1443.8'), - value: new Big('1443.8') // 10 * 144.38 - }, - { - date: '2019-03-01', - grossPerformance: new Big('22.4'), - netPerformance: new Big('22.4'), - investment: new Big('1443.8'), - value: new Big('1466.2') // 10 * 146.62 - }, - { - date: '2019-04-01', - grossPerformance: new Big('47.2'), - netPerformance: new Big('47.2'), - investment: new Big('1443.8'), - value: new Big('1491') // 10 * 149.1 - }, - { - date: '2019-05-01', - grossPerformance: new Big('71.2'), - netPerformance: new Big('71.2'), - investment: new Big('1443.8'), - value: new Big('1515') // 10 * 151.5 - }, - { - date: '2019-06-01', - grossPerformance: new Big('96'), - netPerformance: new Big('96'), - investment: new Big('1443.8'), - value: new Big('1539.8') // 10 * 153.98 - }, - { - date: '2019-07-01', - grossPerformance: new Big('120'), - netPerformance: new Big('120'), - investment: new Big('1443.8'), - value: new Big('1563.8') // 10 * 156.38 - }, - { - date: '2019-08-01', - grossPerformance: new Big('144.8'), - netPerformance: new Big('144.8'), - investment: new Big('1443.8'), - value: new Big('1588.6') // 10 * 158.86 - }, - { - date: '2019-09-01', - grossPerformance: new Big('303.1'), - netPerformance: new Big('303.1'), - investment: new Big('2923.7'), - value: new Big('3226.8') // 20 * 161.34 - }, - { - date: '2019-10-01', - grossPerformance: new Big('351.1'), - netPerformance: new Big('351.1'), - investment: new Big('2923.7'), - value: new Big('3274.8') // 20 * 163.74 - }, - { - date: '2019-11-01', - grossPerformance: new Big('400.7'), - netPerformance: new Big('400.7'), - investment: new Big('2923.7'), - value: new Big('3324.4') // 20 * 166.22 - }, - { - date: '2019-12-01', - grossPerformance: new Big('448.7'), - netPerformance: new Big('448.7'), - investment: new Big('2923.7'), - value: new Big('3372.4') // 20 * 168.62 - }, - { - date: '2020-01-01', - grossPerformance: new Big('498.3'), - netPerformance: new Big('498.3'), - investment: new Big('2923.7'), - value: new Big('3422') // 20 * 171.1 - }, - { - date: '2020-02-01', - grossPerformance: new Big('547.9'), - netPerformance: new Big('547.9'), - investment: new Big('2923.7'), - value: new Big('3471.6') // 20 * 173.58 - }, - { - date: '2020-03-01', - grossPerformance: new Big('226.95'), - netPerformance: new Big('226.95'), - investment: new Big('652.55'), - value: new Big('879.5') // 5 * 175.9 - }, - { - date: '2020-04-01', - grossPerformance: new Big('239.35'), - netPerformance: new Big('239.35'), - investment: new Big('652.55'), - value: new Big('891.9') // 5 * 178.38 - }, - { - date: '2020-05-01', - grossPerformance: new Big('251.35'), - netPerformance: new Big('251.35'), - investment: new Big('652.55'), - value: new Big('903.9') // 5 * 180.78 - }, - { - date: '2020-06-01', - grossPerformance: new Big('263.75'), - netPerformance: new Big('263.75'), - investment: new Big('652.55'), - value: new Big('916.3') // 5 * 183.26 - }, - { - date: '2020-07-01', - grossPerformance: new Big('275.75'), - netPerformance: new Big('275.75'), - investment: new Big('652.55'), - value: new Big('928.3') // 5 * 185.66 - }, - { - date: '2020-08-01', - grossPerformance: new Big('288.15'), - netPerformance: new Big('288.15'), - investment: new Big('652.55'), - value: new Big('940.7') // 5 * 188.14 - }, - { - date: '2020-09-01', - grossPerformance: new Big('300.55'), - netPerformance: new Big('300.55'), - investment: new Big('652.55'), - value: new Big('953.1') // 5 * 190.62 - }, - { - date: '2020-10-01', - grossPerformance: new Big('312.55'), - netPerformance: new Big('312.55'), - investment: new Big('652.55'), - value: new Big('965.1') // 5 * 193.02 - }, - { - date: '2020-11-01', - grossPerformance: new Big('324.95'), - netPerformance: new Big('324.95'), - investment: new Big('652.55'), - value: new Big('977.5') // 5 * 195.5 - }, - { - date: '2020-12-01', - grossPerformance: new Big('336.95'), - netPerformance: new Big('336.95'), - investment: new Big('652.55'), - value: new Big('989.5') // 5 * 197.9 - }, - { - date: '2021-01-01', - grossPerformance: new Big('349.35'), - netPerformance: new Big('349.35'), - investment: new Big('652.55'), - value: new Big('1001.9') // 5 * 200.38 - }, - { - date: '2021-02-01', - grossPerformance: new Big('358.85'), - netPerformance: new Big('358.85'), - investment: new Big('2684.05'), - value: new Big('3042.9') // 15 * 202.86 - }, - { - date: '2021-03-01', - grossPerformance: new Big('392.45'), - netPerformance: new Big('392.45'), - investment: new Big('2684.05'), - value: new Big('3076.5') // 15 * 205.1 - }, - { - date: '2021-04-01', - grossPerformance: new Big('429.65'), - netPerformance: new Big('429.65'), - investment: new Big('2684.05'), - value: new Big('3113.7') // 15 * 207.58 - }, - { - date: '2021-05-01', - grossPerformance: new Big('465.65'), - netPerformance: new Big('465.65'), - investment: new Big('2684.05'), - value: new Big('3149.7') // 15 * 209.98 - }, - { - date: '2021-06-01', - grossPerformance: new Big('502.85'), - netPerformance: new Big('502.85'), - investment: new Big('2684.05'), - value: new Big('3186.9') // 15 * 212.46 - } - ]); - - expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9')); - expect(timelineInfo.minNetPerformance).toEqual(new Big('0')); - }); - - it('with yearly and monthly mixed', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'year' - }, - { - start: '2021-01-01', - accuracy: 'month' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2020-01-01', - grossPerformance: new Big('498.3'), - netPerformance: new Big('498.3'), - investment: new Big('2923.7'), - value: new Big('3422') // 20 * 171.1 - }, - { - date: '2021-01-01', - grossPerformance: new Big('349.35'), - netPerformance: new Big('349.35'), - investment: new Big('652.55'), - value: new Big('1001.9') // 5 * 200.38 - }, - { - date: '2021-02-01', - grossPerformance: new Big('358.85'), - netPerformance: new Big('358.85'), - investment: new Big('2684.05'), - value: new Big('3042.9') // 15 * 202.86 - }, - { - date: '2021-03-01', - grossPerformance: new Big('392.45'), - netPerformance: new Big('392.45'), - investment: new Big('2684.05'), - value: new Big('3076.5') // 15 * 205.1 - }, - { - date: '2021-04-01', - grossPerformance: new Big('429.65'), - netPerformance: new Big('429.65'), - investment: new Big('2684.05'), - value: new Big('3113.7') // 15 * 207.58 - }, - { - date: '2021-05-01', - grossPerformance: new Big('465.65'), - netPerformance: new Big('465.65'), - investment: new Big('2684.05'), - value: new Big('3149.7') // 15 * 209.98 - }, - { - date: '2021-06-01', - grossPerformance: new Big('502.85'), - netPerformance: new Big('502.85'), - investment: new Big('2684.05'), - value: new Big('3186.9') // 15 * 212.46 - } - ]); - }); - - it('with all mixed', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'year' - }, - { - start: '2021-01-01', - accuracy: 'month' - }, - { - start: '2021-06-01', - accuracy: 'day' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2021-06-30' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual( - expect.objectContaining([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2020-01-01', - grossPerformance: new Big('498.3'), - netPerformance: new Big('498.3'), - investment: new Big('2923.7'), - value: new Big('3422') // 20 * 171.1 - }, - { - date: '2021-01-01', - grossPerformance: new Big('349.35'), - netPerformance: new Big('349.35'), - investment: new Big('652.55'), - value: new Big('1001.9') // 5 * 200.38 - }, - { - date: '2021-02-01', - grossPerformance: new Big('358.85'), - netPerformance: new Big('358.85'), - investment: new Big('2684.05'), - value: new Big('3042.9') // 15 * 202.86 - }, - { - date: '2021-03-01', - grossPerformance: new Big('392.45'), - netPerformance: new Big('392.45'), - investment: new Big('2684.05'), - value: new Big('3076.5') // 15 * 205.1 - }, - { - date: '2021-04-01', - grossPerformance: new Big('429.65'), - netPerformance: new Big('429.65'), - investment: new Big('2684.05'), - value: new Big('3113.7') // 15 * 207.58 - }, - { - date: '2021-05-01', - grossPerformance: new Big('465.65'), - netPerformance: new Big('465.65'), - investment: new Big('2684.05'), - value: new Big('3149.7') // 15 * 209.98 - }, - { - date: '2021-06-01', - grossPerformance: new Big('502.85'), - netPerformance: new Big('502.85'), - investment: new Big('2684.05'), - value: new Big('3186.9') // 15 * 212.46 - }, - { - date: '2021-06-02', - grossPerformance: new Big('504.05'), - netPerformance: new Big('504.05'), - investment: new Big('2684.05'), - value: new Big('3188.1') // 15 * 212.54 - }, - { - date: '2021-06-03', - grossPerformance: new Big('505.25'), - netPerformance: new Big('505.25'), - investment: new Big('2684.05'), - value: new Big('3189.3') // 15 * 212.62 - }, - { - date: '2021-06-04', - grossPerformance: new Big('506.45'), - netPerformance: new Big('506.45'), - investment: new Big('2684.05'), - value: new Big('3190.5') // 15 * 212.7 - }, - { - date: '2021-06-05', - grossPerformance: new Big('507.65'), - netPerformance: new Big('507.65'), - investment: new Big('2684.05'), - value: new Big('3191.7') // 15 * 212.78 - }, - { - date: '2021-06-06', - grossPerformance: new Big('508.85'), - netPerformance: new Big('508.85'), - investment: new Big('2684.05'), - value: new Big('3192.9') // 15 * 212.86 - }, - { - date: '2021-06-07', - grossPerformance: new Big('510.05'), - netPerformance: new Big('510.05'), - investment: new Big('2684.05'), - value: new Big('3194.1') // 15 * 212.94 - }, - { - date: '2021-06-08', - grossPerformance: new Big('511.25'), - netPerformance: new Big('511.25'), - investment: new Big('2684.05'), - value: new Big('3195.3') // 15 * 213.02 - }, - { - date: '2021-06-09', - grossPerformance: new Big('512.45'), - netPerformance: new Big('512.45'), - investment: new Big('2684.05'), - value: new Big('3196.5') // 15 * 213.1 - }, - { - date: '2021-06-10', - grossPerformance: new Big('513.65'), - netPerformance: new Big('513.65'), - investment: new Big('2684.05'), - value: new Big('3197.7') // 15 * 213.18 - }, - { - date: '2021-06-11', - grossPerformance: new Big('514.85'), - netPerformance: new Big('514.85'), - investment: new Big('2684.05'), - value: new Big('3198.9') // 15 * 213.26 - }, - { - date: '2021-06-12', - grossPerformance: new Big('516.05'), - netPerformance: new Big('516.05'), - investment: new Big('2684.05'), - value: new Big('3200.1') // 15 * 213.34 - }, - { - date: '2021-06-13', - grossPerformance: new Big('517.25'), - netPerformance: new Big('517.25'), - investment: new Big('2684.05'), - value: new Big('3201.3') // 15 * 213.42 - }, - { - date: '2021-06-14', - grossPerformance: new Big('518.45'), - netPerformance: new Big('518.45'), - investment: new Big('2684.05'), - value: new Big('3202.5') // 15 * 213.5 - }, - { - date: '2021-06-15', - grossPerformance: new Big('519.65'), - netPerformance: new Big('519.65'), - investment: new Big('2684.05'), - value: new Big('3203.7') // 15 * 213.58 - }, - { - date: '2021-06-16', - grossPerformance: new Big('520.85'), - netPerformance: new Big('520.85'), - investment: new Big('2684.05'), - value: new Big('3204.9') // 15 * 213.66 - }, - { - date: '2021-06-17', - grossPerformance: new Big('522.05'), - netPerformance: new Big('522.05'), - investment: new Big('2684.05'), - value: new Big('3206.1') // 15 * 213.74 - }, - { - date: '2021-06-18', - grossPerformance: new Big('523.25'), - netPerformance: new Big('523.25'), - investment: new Big('2684.05'), - value: new Big('3207.3') // 15 * 213.82 - }, - { - date: '2021-06-19', - grossPerformance: new Big('524.45'), - netPerformance: new Big('524.45'), - investment: new Big('2684.05'), - value: new Big('3208.5') // 15 * 213.9 - }, - { - date: '2021-06-20', - grossPerformance: new Big('525.65'), - netPerformance: new Big('525.65'), - investment: new Big('2684.05'), - value: new Big('3209.7') // 15 * 213.98 - }, - { - date: '2021-06-21', - grossPerformance: new Big('526.85'), - netPerformance: new Big('526.85'), - investment: new Big('2684.05'), - value: new Big('3210.9') // 15 * 214.06 - }, - { - date: '2021-06-22', - grossPerformance: new Big('528.05'), - netPerformance: new Big('528.05'), - investment: new Big('2684.05'), - value: new Big('3212.1') // 15 * 214.14 - }, - { - date: '2021-06-23', - grossPerformance: new Big('529.25'), - netPerformance: new Big('529.25'), - investment: new Big('2684.05'), - value: new Big('3213.3') // 15 * 214.22 - }, - { - date: '2021-06-24', - grossPerformance: new Big('530.45'), - netPerformance: new Big('530.45'), - investment: new Big('2684.05'), - value: new Big('3214.5') // 15 * 214.3 - }, - { - date: '2021-06-25', - grossPerformance: new Big('531.65'), - netPerformance: new Big('531.65'), - investment: new Big('2684.05'), - value: new Big('3215.7') // 15 * 214.38 - }, - { - date: '2021-06-26', - grossPerformance: new Big('532.85'), - netPerformance: new Big('532.85'), - investment: new Big('2684.05'), - value: new Big('3216.9') // 15 * 214.46 - }, - { - date: '2021-06-27', - grossPerformance: new Big('534.05'), - netPerformance: new Big('534.05'), - investment: new Big('2684.05'), - value: new Big('3218.1') // 15 * 214.54 - }, - { - date: '2021-06-28', - grossPerformance: new Big('535.25'), - netPerformance: new Big('535.25'), - investment: new Big('2684.05'), - value: new Big('3219.3') // 15 * 214.62 - }, - { - date: '2021-06-29', - grossPerformance: new Big('536.45'), - netPerformance: new Big('536.45'), - investment: new Big('2684.05'), - value: new Big('3220.5') // 15 * 214.7 - }, - { - date: '2021-06-30', - grossPerformance: new Big('537.65'), - netPerformance: new Big('537.65'), - investment: new Big('2684.05'), - value: new Big('3221.7') // 15 * 214.78 - } - ]) - ); - }); - - it('with mixed portfolio', async () => { - const portfolioCalculator = new PortfolioCalculator( - currentRateService, - 'USD' - ); - portfolioCalculator.setTransactionPoints([ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - }, - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - } - ] - } - ]); - const timelineSpecification: TimelineSpecification[] = [ - { - start: '2019-01-01', - accuracy: 'year' - } - ]; - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - '2020-01-01' - ); - const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods; - - expect(timeline).toEqual([ - { - date: '2019-01-01', - grossPerformance: new Big('0'), - netPerformance: new Big('0'), - investment: new Big('0'), - value: new Big('0') - }, - { - date: '2020-01-01', - grossPerformance: new Big('267.2'), - netPerformance: new Big('267.2'), - investment: new Big('11553.75'), - value: new Big('11820.95') // 10 * 171.1 + 5 * 2021.99 - } - ]); - }); - }); - describe('annualized performance percentage', () => { - const portfolioCalculator = new PortfolioCalculator( + const portfolioCalculator = new PortfolioCalculator({ currentRateService, - 'USD' - ); + currency: 'USD', + orders: [] + }); it('Get annualized performance', async () => { expect( @@ -2685,351 +71,3 @@ describe('PortfolioCalculator', () => { }); }); }); - -const ordersMixedSymbols: PortfolioOrder[] = [ - { - date: '2017-01-03', - name: 'Tesla, Inc.', - quantity: new Big('50'), - symbol: 'TSLA', - type: 'BUY', - unitPrice: new Big('42.97'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2017-07-01', - name: 'Bitcoin USD', - quantity: new Big('0.5614682'), - symbol: 'BTCUSD', - type: 'BUY', - unitPrice: new Big('3562.089535970158'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2018-09-01', - name: 'Amazon.com, Inc.', - quantity: new Big('5'), - symbol: 'AMZN', - type: 'BUY', - unitPrice: new Big('2021.99'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - } -]; - -const ordersVTI: PortfolioOrder[] = [ - { - date: '2019-02-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('144.38'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2019-08-03', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('147.99'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2020-02-02', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('15'), - symbol: 'VTI', - type: 'SELL', - unitPrice: new Big('151.41'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2021-08-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('177.69'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - }, - { - date: '2021-02-01', - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('10'), - symbol: 'VTI', - type: 'BUY', - unitPrice: new Big('203.15'), - currency: 'USD', - dataSource: DataSource.YAHOO, - fee: new Big(0) - } -]; - -const orderTslaTransactionPoint: TransactionPoint[] = [ - { - date: '2021-01-01', - items: [ - { - quantity: new Big('1'), - symbol: 'TSLA', - investment: new Big('719.46'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2021-01-01', - fee: new Big(0), - transactionCount: 1 - } - ] - } -]; - -const ordersVTITransactionPoints: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - quantity: new Big('15'), - symbol: 'VTI', - investment: new Big('2684.05'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 4 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - quantity: new Big('25'), - symbol: 'VTI', - investment: new Big('4460.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 5 - } - ] - } -]; - -const transactionPointsBuyAndSell: TransactionPoint[] = [ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2019-09-01', - items: [ - { - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 1 - }, - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 3 - } - ] - }, - { - date: '2020-08-02', - items: [ - { - quantity: new Big('0'), - symbol: 'AMZN', - investment: new Big('0'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 2 - }, - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - quantity: new Big('0'), - symbol: 'AMZN', - investment: new Big('0'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 2 - }, - { - quantity: new Big('15'), - symbol: 'VTI', - investment: new Big('2684.05'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 4 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - quantity: new Big('0'), - symbol: 'AMZN', - investment: new Big('0'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-09-01', - fee: new Big(0), - transactionCount: 2 - }, - { - quantity: new Big('25'), - symbol: 'VTI', - investment: new Big('4460.95'), - currency: 'USD', - dataSource: DataSource.YAHOO, - firstBuyDate: '2019-02-01', - fee: new Big(0), - transactionCount: 5 - } - ] - } -]; diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 2dd11e0e..20b0a870 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1,15 +1,15 @@ 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 { ResponseError, 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, @@ -17,11 +17,12 @@ import { max, min } from 'date-fns'; -import { flatten, isNumber } from 'lodash'; +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 { @@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in import { TransactionPoint } from './interfaces/transaction-point.interface'; export class PortfolioCalculator { + private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT = + true; + + private static readonly ENABLE_LOGGING = false; + + private currency: string; + private currentRateService: CurrentRateService; + private orders: PortfolioOrder[]; private transactionPoints: TransactionPoint[]; - public constructor( - private currentRateService: CurrentRateService, - private currency: string - ) {} + public constructor({ + currency, + currentRateService, + orders + }: { + currency: string; + currentRateService: CurrentRateService; + orders: PortfolioOrder[]; + }) { + this.currency = currency; + this.currentRateService = currentRateService; + this.orders = orders; - public computeTransactionPoints(orders: PortfolioOrder[]) { - orders.sort((a, b) => a.date.localeCompare(b.date)); + 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 orders) { + for (const order of this.orders) { const currentDate = order.date; let currentTransactionPointItem: TransactionPointSymbol; @@ -140,7 +158,6 @@ export class PortfolioCalculator { hasErrors: false, grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), - netAnnualizedPerformance: new Big(0), netPerformance: new Big(0), netPerformancePercentage: new Big(0), positions: [], @@ -195,6 +212,7 @@ export class PortfolioCalculator { const marketSymbolMap: { [date: string]: { [symbol: string]: Big }; } = {}; + for (const marketSymbol of marketSymbols) { const date = format(marketSymbol.date, DATE_FORMAT); if (!marketSymbolMap[date]) { @@ -207,112 +225,37 @@ export class PortfolioCalculator { } } - let hasErrors = false; - const startString = format(start, DATE_FORMAT); - - const holdingPeriodReturns: { [symbol: string]: Big } = {}; - const netHoldingPeriodReturns: { [symbol: string]: Big } = {}; - const grossPerformance: { [symbol: string]: Big } = {}; - const netPerformance: { [symbol: string]: Big } = {}; const todayString = format(today, DATE_FORMAT); if (firstIndex > 0) { firstIndex--; } - const invalidSymbols = []; - const lastInvestments: { [symbol: string]: Big } = {}; - const lastQuantities: { [symbol: string]: Big } = {}; - const lastFees: { [symbol: string]: Big } = {}; const initialValues: { [symbol: string]: Big } = {}; - for (let i = firstIndex; i < this.transactionPoints.length; i++) { - const currentDate = - i === firstIndex ? startString : this.transactionPoints[i].date; - const nextDate = - i + 1 < this.transactionPoints.length - ? this.transactionPoints[i + 1].date - : todayString; - - const items = this.transactionPoints[i].items; - for (const item of items) { - if (!marketSymbolMap[nextDate]?.[item.symbol]) { - invalidSymbols.push(item.symbol); - hasErrors = true; - Logger.warn( - `Missing value for symbol ${item.symbol} at ${nextDate}`, - 'PortfolioCalculator' - ); - continue; - } - let lastInvestment: Big = new Big(0); - let lastQuantity: Big = item.quantity; - if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) { - lastInvestment = item.investment.minus(lastInvestments[item.symbol]); - lastQuantity = lastQuantities[item.symbol]; - } - - const itemValue = marketSymbolMap[currentDate]?.[item.symbol]; - let initialValue = itemValue?.mul(lastQuantity); - let investedValue = itemValue?.mul(item.quantity); - const isFirstOrderAndIsStartBeforeCurrentDate = - i === firstIndex && - isBefore(parseDate(this.transactionPoints[i].date), start); - const lastFee: Big = lastFees[item.symbol] ?? new Big(0); - const fee = isFirstOrderAndIsStartBeforeCurrentDate - ? new Big(0) - : item.fee.minus(lastFee); - if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) { - initialValue = item.investment; - investedValue = item.investment; - } - if (i === firstIndex || !initialValues[item.symbol]) { - initialValues[item.symbol] = initialValue; - } - if (!item.quantity.eq(0)) { - if (!initialValue) { - invalidSymbols.push(item.symbol); - hasErrors = true; - Logger.warn( - `Missing value for symbol ${item.symbol} at ${currentDate}`, - 'PortfolioCalculator' - ); - continue; - } - - const cashFlow = lastInvestment; - const endValue = marketSymbolMap[nextDate][item.symbol].mul( - item.quantity - ); - - const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow)); - holdingPeriodReturns[item.symbol] = ( - holdingPeriodReturns[item.symbol] ?? new Big(1) - ).mul(holdingPeriodReturn); - grossPerformance[item.symbol] = ( - grossPerformance[item.symbol] ?? new Big(0) - ).plus(endValue.minus(investedValue)); - - const netHoldingPeriodReturn = endValue.div( - initialValue.plus(cashFlow).plus(fee) - ); - netHoldingPeriodReturns[item.symbol] = ( - netHoldingPeriodReturns[item.symbol] ?? new Big(1) - ).mul(netHoldingPeriodReturn); - netPerformance[item.symbol] = ( - netPerformance[item.symbol] ?? new Big(0) - ).plus(endValue.minus(investedValue).minus(fee)); - } - lastInvestments[item.symbol] = item.investment; - lastQuantities[item.symbol] = item.quantity; - lastFees[item.symbol] = item.fee; - } - } - const positions: TimelinePosition[] = []; + let hasAnySymbolMetricsErrors = false; + + const errors: ResponseError['errors'] = []; for (const item of lastTransactionPoint.items) { const marketValue = marketSymbolMap[todayString]?.[item.symbol]; - const isValid = invalidSymbols.indexOf(item.symbol) === -1; + + const { + grossPerformance, + grossPerformancePercentage, + hasErrors, + initialValue, + netPerformance, + netPerformancePercentage + } = this.getSymbolMetrics({ + marketSymbolMap, + start, + symbol: item.symbol + }); + + hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; + initialValues[item.symbol] = initialValue; + positions.push({ averagePrice: item.quantity.eq(0) ? new Big(0) @@ -320,31 +263,33 @@ export class PortfolioCalculator { currency: item.currency, dataSource: item.dataSource, firstBuyDate: item.firstBuyDate, - grossPerformance: isValid - ? grossPerformance[item.symbol] ?? null + grossPerformance: !hasErrors ? grossPerformance ?? null : null, + grossPerformancePercentage: !hasErrors + ? grossPerformancePercentage ?? null : null, - grossPerformancePercentage: - isValid && holdingPeriodReturns[item.symbol] - ? holdingPeriodReturns[item.symbol].minus(1) - : null, investment: item.investment, marketPrice: marketValue?.toNumber() ?? null, - netPerformance: isValid ? netPerformance[item.symbol] ?? null : null, - netPerformancePercentage: - isValid && netHoldingPeriodReturns[item.symbol] - ? netHoldingPeriodReturns[item.symbol].minus(1) - : null, + netPerformance: !hasErrors ? netPerformance ?? null : null, + netPerformancePercentage: !hasErrors + ? netPerformancePercentage ?? null + : null, quantity: item.quantity, symbol: item.symbol, transactionCount: item.transactionCount }); + + if (hasErrors) { + errors.push({ dataSource: item.dataSource, symbol: item.symbol }); + } } + const overall = this.calculateOverallPerformance(positions, initialValues); return { ...overall, + errors, positions, - hasErrors: hasErrors || overall.hasErrors + hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors }; } @@ -462,20 +407,16 @@ export class PortfolioCalculator { private calculateOverallPerformance( positions: TimelinePosition[], - initialValues: { [p: string]: Big } + initialValues: { [symbol: 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 hasErrors = false; 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()); + let sumOfWeights = new Big(0); + let totalInvestment = new Big(0); for (const currentPosition of positions) { if (currentPosition.marketPrice) { @@ -485,36 +426,34 @@ export class PortfolioCalculator { } else { hasErrors = true; } + totalInvestment = totalInvestment.plus(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); + if (currentPosition.grossPerformancePercentage) { + // Use the average from the initial value and the current investment as + // a weight + const weight = (initialValues[currentPosition.symbol] ?? new Big(0)) + .plus(currentPosition.investment) + .div(2); + + sumOfWeights = sumOfWeights.plus(weight); + grossPerformancePercentage = grossPerformancePercentage.plus( - currentPosition.grossPerformancePercentage.mul(currentInitialValue) - ); - netAnnualizedPerformance = netAnnualizedPerformance.plus( - this.getAnnualizedPerformancePercent({ - daysInMarket: differenceInDays( - today, - parseDate(currentPosition.firstBuyDate) - ), - netPerformancePercent: currentPosition.netPerformancePercentage - }).mul(currentInitialValue) + currentPosition.grossPerformancePercentage.mul(weight) ); + netPerformancePercentage = netPerformancePercentage.plus( - currentPosition.netPerformancePercentage.mul(currentInitialValue) + currentPosition.netPerformancePercentage.mul(weight) ); } else if (!currentPosition.quantity.eq(0)) { Logger.warn( @@ -525,13 +464,12 @@ export class PortfolioCalculator { } } - if (!completeInitialValue.eq(0)) { - grossPerformancePercentage = - grossPerformancePercentage.div(completeInitialValue); - netPerformancePercentage = - netPerformancePercentage.div(completeInitialValue); - netAnnualizedPerformance = - netAnnualizedPerformance.div(completeInitialValue); + if (sumOfWeights.gt(0)) { + grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights); + netPerformancePercentage = netPerformancePercentage.div(sumOfWeights); + } else { + grossPerformancePercentage = new Big(0); + netPerformancePercentage = new Big(0); } return { @@ -539,7 +477,6 @@ export class PortfolioCalculator { grossPerformance, grossPerformancePercentage, hasErrors, - netAnnualizedPerformance, netPerformance, netPerformancePercentage, totalInvestment @@ -693,6 +630,356 @@ export class PortfolioCalculator { } } + private 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 averagePriceAtEndDate = new Big(0); + let averagePriceAtStartDate = 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 investmentAtStartDate: Big; + let lastAveragePrice = new Big(0); + let lastTransactionInvestment = new Big(0); + let lastValueOfInvestmentBeforeTransaction = new Big(0); + let maxTotalInvestment = new Big(0); + let timeWeightedGrossPerformancePercentage = new Big(1); + let timeWeightedNetPerformancePercentage = new Big(1); + let totalInvestment = new Big(0); + let totalInvestmentWithGrossPerformanceFromSell = new Big(0); + let totalUnits = new Big(0); + let valueAtStartDate: Big; + + // 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 + }); + + 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 + }); + + // 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'; + }); + + const indexOfEndOrder = orders.findIndex((order) => { + return order.itemType === 'end'; + }); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + indexOfStartOrder === 0 + ? orders[i + 1]?.unitPrice + : unitPriceAtStartDate; + } + + // Calculate the average start price as soon as any units are held + if ( + averagePriceAtStartDate.eq(0) && + i >= indexOfStartOrder && + totalUnits.gt(0) + ) { + averagePriceAtStartDate = totalInvestment.div(totalUnits); + } + + const valueOfInvestmentBeforeTransaction = totalUnits.mul( + order.unitPrice + ); + + if (!investmentAtStartDate && i >= indexOfStartOrder) { + investmentAtStartDate = totalInvestment ?? new Big(0); + valueAtStartDate = valueOfInvestmentBeforeTransaction; + } + + const transactionInvestment = order.quantity + .mul(order.unitPrice) + .mul(this.getFactor(order.type)); + + totalInvestment = totalInvestment.plus(transactionInvestment); + + if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { + maxTotalInvestment = totalInvestment; + } + + if (i === indexOfEndOrder && totalUnits.gt(0)) { + averagePriceAtEndDate = totalInvestment.div(totalUnits); + } + + if (i >= indexOfStartOrder && !initialValue) { + if ( + i === indexOfStartOrder && + !valueOfInvestmentBeforeTransaction.eq(0) + ) { + initialValue = valueOfInvestmentBeforeTransaction; + } else if (transactionInvestment.gt(0)) { + initialValue = transactionInvestment; + } + } + + fees = fees.plus(order.fee); + + totalUnits = totalUnits.plus( + order.quantity.mul(this.getFactor(order.type)) + ); + + const valueOfInvestment = totalUnits.mul(order.unitPrice); + + const grossPerformanceFromSell = + order.type === TypeOfOrder.SELL + ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + totalInvestmentWithGrossPerformanceFromSell = + totalInvestmentWithGrossPerformanceFromSell + .plus(transactionInvestment) + .plus(grossPerformanceFromSell); + + lastAveragePrice = totalUnits.eq(0) + ? new Big(0) + : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); + + const newGrossPerformance = valueOfInvestment + .minus(totalInvestmentWithGrossPerformanceFromSell) + .plus(grossPerformanceFromSells); + + if ( + i > indexOfStartOrder && + !lastValueOfInvestmentBeforeTransaction + .plus(lastTransactionInvestment) + .eq(0) + ) { + const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction + .minus( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ) + .div( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ); + + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.mul( + new Big(1).plus(grossHoldingPeriodReturn) + ); + + const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction + .minus(fees.minus(feesAtStartDate)) + .minus( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ) + .div( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ); + + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.mul( + new Big(1).plus(netHoldingPeriodReturn) + ); + } + + grossPerformance = newGrossPerformance; + + lastTransactionInvestment = transactionInvestment; + + lastValueOfInvestmentBeforeTransaction = + valueOfInvestmentBeforeTransaction; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + grossPerformanceAtStartDate = grossPerformance; + } + } + + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.minus(1); + + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.minus(1); + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( + maxTotalInvestment.minus(investmentAtStartDate) + ); + + const grossPerformancePercentage = + PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || + averagePriceAtStartDate.eq(0) || + averagePriceAtEndDate.eq(0) || + orders[indexOfStartOrder].unitPrice.eq(0) + ? maxInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) + : new Big(0) + : // This formula has the issue that buying more units with a price + // lower than the average buying price results in a positive + // performance even if the market price stays constant + unitPriceAtEndDate + .div(averagePriceAtEndDate) + .div( + orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) + ) + .minus(1); + + const feesPerUnit = totalUnits.gt(0) + ? fees.minus(feesAtStartDate).div(totalUnits) + : new Big(0); + + const netPerformancePercentage = + PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT || + averagePriceAtStartDate.eq(0) || + averagePriceAtEndDate.eq(0) || + orders[indexOfStartOrder].unitPrice.eq(0) + ? maxInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) + : new Big(0) + : // This formula has the issue that buying more units with a price + // lower than the average buying price results in a positive + // performance even if the market price stays constant + unitPriceAtEndDate + .minus(feesPerUnit) + .div(averagePriceAtEndDate) + .div( + orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) + ) + .minus(1); + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + ` + ${symbol} + Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( + 2 + )} -> ${unitPriceAtEndDate.toFixed(2)} + Average price: ${averagePriceAtStartDate.toFixed( + 2 + )} -> ${averagePriceAtEndDate.toFixed(2)} + Max. total investment: ${maxTotalInvestment.toFixed(2)} + Gross performance: ${totalGrossPerformance.toFixed( + 2 + )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% + Fees per unit: ${feesPerUnit.toFixed(2)} + Net performance: ${totalNetPerformance.toFixed( + 2 + )} / ${netPerformancePercentage.mul(100).toFixed(2)}%` + ); + } + + return { + initialValue, + grossPerformancePercentage, + netPerformancePercentage, + hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + grossPerformance: totalGrossPerformance + }; + } + private isNextItemActive( timelineSpecification: TimelineSpecification[], currentDate: Date, diff --git a/apps/api/src/app/portfolio/portfolio-service.strategy.ts b/apps/api/src/app/portfolio/portfolio-service.strategy.ts deleted file mode 100644 index a85b2885..00000000 --- a/apps/api/src/app/portfolio/portfolio-service.strategy.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RequestWithUser } from '@ghostfolio/common/types'; -import { Inject, Injectable } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; - -import { PortfolioService } from './portfolio.service'; -import { PortfolioServiceNew } from './portfolio.service-new'; - -@Injectable() -export class PortfolioServiceStrategy { - public constructor( - private readonly portfolioService: PortfolioService, - private readonly portfolioServiceNew: PortfolioServiceNew, - @Inject(REQUEST) private readonly request: RequestWithUser - ) {} - - public get(newCalculationEngine?: boolean) { - if ( - newCalculationEngine || - this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true - ) { - 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 a47f4303..ad06dbb5 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -38,7 +38,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; -import { PortfolioServiceStrategy } from './portfolio-service.strategy'; +import { PortfolioService } from './portfolio.service'; @Controller('portfolio') export class PortfolioController { @@ -46,7 +46,7 @@ export class PortfolioController { private readonly accessService: AccessService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly portfolioServiceStrategy: PortfolioServiceStrategy, + private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -57,9 +57,10 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId: string, @Query('range') range ): Promise { - const historicalDataContainer = await this.portfolioServiceStrategy - .get() - .getChart(impersonationId, range); + const historicalDataContainer = await this.portfolioService.getChart( + impersonationId, + range + ); let chartData = historicalDataContainer.items; @@ -109,9 +110,11 @@ export class PortfolioController { let hasError = false; const { accounts, holdings, hasErrors } = - await this.portfolioServiceStrategy - .get(true) - .getDetails(impersonationId, this.request.user.id, range); + await this.portfolioService.getDetails( + impersonationId, + this.request.user.id, + range + ); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { hasError = true; @@ -174,9 +177,9 @@ export class PortfolioController { ); } - let investments = await this.portfolioServiceStrategy - .get() - .getInvestments(impersonationId); + let investments = await this.portfolioService.getInvestments( + impersonationId + ); if ( impersonationId || @@ -203,9 +206,10 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId: string, @Query('range') range ): Promise { - const performanceInformation = await this.portfolioServiceStrategy - .get() - .getPerformance(impersonationId, range); + const performanceInformation = await this.portfolioService.getPerformance( + impersonationId, + range + ); if ( impersonationId || @@ -228,9 +232,10 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId: string, @Query('range') range ): Promise { - const result = await this.portfolioServiceStrategy - .get() - .getPositions(impersonationId, range); + const result = await this.portfolioService.getPositions( + impersonationId, + range + ); if ( impersonationId || @@ -270,9 +275,10 @@ export class PortfolioController { hasDetails = user.subscription.type === 'Premium'; } - const { holdings } = await this.portfolioServiceStrategy - .get(true) - .getDetails(access.userId, access.userId); + const { holdings } = await this.portfolioService.getDetails( + access.userId, + access.userId + ); const portfolioPublicDetails: PortfolioPublicDetails = { hasDetails, @@ -324,9 +330,7 @@ export class PortfolioController { ); } - let summary = await this.portfolioServiceStrategy - .get() - .getSummary(impersonationId); + let summary = await this.portfolioService.getSummary(impersonationId); if ( impersonationId || @@ -360,9 +364,11 @@ export class PortfolioController { @Param('dataSource') dataSource, @Param('symbol') symbol ): Promise { - let position = await this.portfolioServiceStrategy - .get() - .getPosition(dataSource, impersonationId, symbol); + let position = await this.portfolioService.getPosition( + dataSource, + impersonationId, + symbol + ); if (position) { if ( @@ -403,6 +409,6 @@ export class PortfolioController { ); } - return await this.portfolioServiceStrategy.get().getReport(impersonationId); + return await this.portfolioService.getReport(impersonationId); } } diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 5204f179..7e6dfe88 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -13,15 +13,13 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod import { Module } from '@nestjs/common'; import { CurrentRateService } from './current-rate.service'; -import { PortfolioServiceStrategy } from './portfolio-service.strategy'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; -import { PortfolioServiceNew } from './portfolio.service-new'; import { RulesService } from './rules.service'; @Module({ controllers: [PortfolioController], - exports: [PortfolioServiceStrategy], + exports: [PortfolioService], imports: [ AccessModule, ConfigurationModule, @@ -39,8 +37,6 @@ import { RulesService } from './rules.service'; AccountService, CurrentRateService, PortfolioService, - PortfolioServiceNew, - PortfolioServiceStrategy, RulesService ] }) diff --git a/apps/api/src/app/portfolio/portfolio.service-new.ts b/apps/api/src/app/portfolio/portfolio.service-new.ts deleted file mode 100644 index e3b9e853..00000000 --- a/apps/api/src/app/portfolio/portfolio.service-new.ts +++ /dev/null @@ -1,1324 +0,0 @@ -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 { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; -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 { - ASSET_SUB_CLASS_EMERGENCY_FUND, - UNKNOWN_KEY, - baseCurrency -} from '@ghostfolio/common/config'; -import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; -import { - Accounts, - PortfolioDetails, - PortfolioPerformanceResponse, - PortfolioReport, - PortfolioSummary, - Position, - TimelinePosition -} from '@ghostfolio/common/interfaces'; -import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; -import type { - AccountWithValue, - DateRange, - Market, - 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 { - differenceInDays, - 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'; - -const developedMarkets = require('../../assets/countries/developed-markets.json'); -const emergingMarkets = require('../../assets/countries/emerging-markets.json'); - -@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, - private readonly userService: UserService - ) {} - - 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 valueInBaseCurrency = details.accounts[account.id]?.current ?? 0; - - const result = { - ...account, - transactionCount, - valueInBaseCurrency, - balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ), - value: this.exchangeRateDataService.toCurrency( - valueInBaseCurrency, - userCurrency, - account.currency - ) - }; - - delete result.Order; - - return result; - }); - } - - public async getAccountsWithAggregations(aUserId: string): Promise { - const accounts = await this.getAccounts(aUserId); - let totalBalanceInBaseCurrency = new Big(0); - let totalValueInBaseCurrency = new Big(0); - let transactionCount = 0; - - for (const account of accounts) { - totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus( - account.balanceInBaseCurrency - ); - totalValueInBaseCurrency = totalValueInBaseCurrency.plus( - account.valueInBaseCurrency - ); - transactionCount += account.transactionCount; - } - - return { - accounts, - transactionCount, - totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), - totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber() - }; - } - - 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 user = await this.userService.user({ id: userId }); - - const emergencyFund = new Big( - (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 - ); - const userCurrency = - this.request.user?.Settings?.currency ?? - user.Settings?.currency ?? - baseCurrency; - - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - userId - }); - - const portfolioCalculator = new PortfolioCalculatorNew({ - currency: userCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - 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.balanceInBaseCurrency - ); - const totalValue = currentPositions.currentValue.plus( - cashDetails.balanceInBaseCurrency - ); - - 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.getQuotes(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]; - - const markets: { [key in Market]: number } = { - developedMarkets: 0, - emergingMarkets: 0, - otherMarkets: 0 - }; - - for (const country of symbolProfile.countries) { - if (developedMarkets.includes(country.code)) { - markets.developedMarkets = new Big(markets.developedMarkets) - .plus(country.weight) - .toNumber(); - } else if (emergingMarkets.includes(country.code)) { - markets.emergingMarkets = new Big(markets.emergingMarkets) - .plus(country.weight) - .toNumber(); - } else { - markets.otherMarkets = new Big(markets.otherMarkets) - .plus(country.weight) - .toNumber(); - } - } - - holdings[item.symbol] = { - markets, - allocationCurrent: value.div(totalValue).toNumber(), - allocationInvestment: item.investment.div(totalInvestment).toNumber(), - assetClass: symbolProfile.assetClass, - assetSubClass: symbolProfile.assetSubClass, - countries: symbolProfile.countries, - currency: item.currency, - dataSource: symbolProfile.dataSource, - 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, - emergencyFund, - 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( - aDataSource: DataSource, - aImpersonationId: string, - aSymbol: string - ): Promise { - const userCurrency = this.request.user.Settings.currency; - const userId = await this.getUserId(aImpersonationId, this.request.user.id); - - const orders = ( - await this.orderService.getOrders({ userCurrency, userId }) - ).filter(({ SymbolProfile }) => { - return ( - SymbolProfile.dataSource === aDataSource && - SymbolProfile.symbol === aSymbol - ); - }); - - if (orders.length <= 0) { - return { - averagePrice: undefined, - firstBuyDate: undefined, - grossPerformance: undefined, - grossPerformancePercent: undefined, - historicalData: [], - investment: undefined, - marketPrice: undefined, - maxPrice: undefined, - minPrice: undefined, - netPerformance: undefined, - netPerformancePercent: undefined, - orders: [], - quantity: undefined, - SymbolProfile: undefined, - transactionCount: undefined, - value: undefined - }; - } - - const positionCurrency = orders[0].SymbolProfile.currency; - const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ - aSymbol - ]); - - const portfolioOrders: PortfolioOrder[] = orders - .filter((order) => { - return order.type === 'BUY' || order.type === 'SELL'; - }) - .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, - 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 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 { - firstBuyDate, - grossPerformance, - investment, - marketPrice, - maxPrice, - minPrice, - netPerformance, - orders, - SymbolProfile, - transactionCount, - averagePrice: averagePrice.toNumber(), - grossPerformancePercent: - position.grossPerformancePercentage?.toNumber(), - historicalData: historicalDataArray, - netPerformancePercent: position.netPerformancePercentage?.toNumber(), - quantity: quantity.toNumber(), - value: this.exchangeRateDataService.toCurrency( - quantity.mul(marketPrice).toNumber(), - currency, - userCurrency - ) - }; - } else { - const currentData = await this.dataProviderService.getQuotes([ - { 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 { - marketPrice, - maxPrice, - minPrice, - orders, - SymbolProfile, - averagePrice: 0, - firstBuyDate: undefined, - grossPerformance: undefined, - grossPerformancePercent: undefined, - historicalData: historicalDataArray, - investment: 0, - netPerformance: undefined, - netPerformancePercent: undefined, - quantity: 0, - 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.getQuotes(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 { - 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: { - 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 currentValue = currentPositions.currentValue.toNumber(); - const currentGrossPerformance = currentPositions.grossPerformance; - let currentGrossPerformancePercent = - currentPositions.grossPerformancePercentage; - const currentNetPerformance = currentPositions.netPerformance; - let currentNetPerformancePercent = - currentPositions.netPerformancePercentage; - - if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) { - // If algebraic sign is different, harmonize it - currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1); - } - - if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) { - // If algebraic sign is different, harmonize it - currentNetPerformancePercent = currentNetPerformancePercent.mul(-1); - } - - return { - errors: currentPositions.errors, - hasErrors: currentPositions.hasErrors || hasErrors, - performance: { - currentValue, - currentGrossPerformance: currentGrossPerformance.toNumber(), - currentGrossPerformancePercent: - currentGrossPerformancePercent.toNumber(), - currentNetPerformance: currentNetPerformance.toNumber(), - currentNetPerformancePercent: currentNetPerformancePercent.toNumber() - } - }; - } - - 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 userCurrency = this.request.user.Settings.currency; - const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - - const performanceInformation = await this.getPerformance(aImpersonationId); - - const { balanceInBaseCurrency } = await this.accountService.getCashDetails( - userId, - userCurrency - ); - const orders = await this.orderService.getOrders({ - userCurrency, - userId - }); - const dividend = this.getDividend(orders).toNumber(); - const emergencyFund = new Big( - (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 - ); - const fees = this.getFees(orders).toNumber(); - const firstOrderDate = orders[0]?.date; - const items = this.getItems(orders).toNumber(); - - const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); - const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); - - const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); - - const netWorth = new Big(balanceInBaseCurrency) - .plus(performanceInformation.performance.currentValue) - .plus(items) - .toNumber(); - - const daysInMarket = differenceInDays(new Date(), firstOrderDate); - - const annualizedPerformancePercent = new PortfolioCalculatorNew({ - currency: userCurrency, - currentRateService: this.currentRateService, - orders: [] - }) - .getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercent - ) - }) - ?.toNumber(); - - return { - ...performanceInformation.performance, - annualizedPerformancePercent, - cash, - dividend, - fees, - firstOrderDate, - items, - netWorth, - totalBuy, - totalSell, - committedFunds: committedFunds.toNumber(), - emergencyFund: emergencyFund.toNumber(), - ordersCount: orders.filter((order) => { - return order.type === 'BUY' || order.type === 'SELL'; - }).length - }; - } - - private async getCashPositions({ - cashDetails, - emergencyFund, - investment, - userCurrency, - value - }: { - cashDetails: CashDetails; - emergencyFund: Big; - investment: Big; - value: Big; - userCurrency: string; - }) { - const cashPositions: PortfolioDetails['holdings'] = {}; - - 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, - dataSource: undefined, - 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 - }; - } - } - - if (emergencyFund.gt(0)) { - cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = { - ...cashPositions[userCurrency], - assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND, - investment: emergencyFund.toNumber(), - name: ASSET_SUB_CLASS_EMERGENCY_FUND, - symbol: ASSET_SUB_CLASS_EMERGENCY_FUND, - value: emergencyFund.toNumber() - }; - - cashPositions[userCurrency].investment = new Big( - cashPositions[userCurrency].investment - ) - .minus(emergencyFund) - .toNumber(); - cashPositions[userCurrency].value = new Big( - cashPositions[userCurrency].value - ) - .minus(emergencyFund) - .toNumber(); - } - - 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.SymbolProfile.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.SymbolProfile.currency, - this.request.user.Settings.currency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - - private getItems(orders: OrderWithAccount[], date = new Date(0)) { - return orders - .filter((order) => { - // Filter out all orders before given date and type item - return ( - isBefore(date, new Date(order.date)) && - order.type === TypeOfOrder.ITEM - ); - }) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - new Big(order.quantity).mul(order.unitPrice).toNumber(), - order.SymbolProfile.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 userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; - - const orders = await this.orderService.getOrders({ - includeDrafts, - userCurrency, - userId, - types: ['BUY', 'SELL'] - }); - - if (orders.length <= 0) { - return { transactionPoints: [], orders: [], portfolioOrders: [] }; - } - - const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big( - this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - userCurrency - ) - ), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - type: order.type, - unitPrice: new Big( - this.exchangeRateDataService.toCurrency( - order.unitPrice, - order.SymbolProfile.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; - }); - - accounts[account.id] = { - balance: account.balance, - currency: account.currency, - current: account.balance, - name: account.name, - original: account.balance - }; - - for (const order of ordersByAccount) { - let currentValueOfSymbol = - order.quantity * - portfolioItemsNow[order.SymbolProfile.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.SymbolProfile.currency, - currency - ); - }) - .reduce((previous, current) => previous + current, 0); - } -} diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index f8e61743..a7dc8adf 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s 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 { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator'; import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; @@ -41,6 +40,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in import type { AccountWithValue, DateRange, + Market, OrderWithAccount, RequestWithUser } from '@ghostfolio/common/types'; @@ -49,6 +49,7 @@ import { REQUEST } from '@nestjs/core'; import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; import { + differenceInDays, endOfToday, format, isAfter, @@ -68,8 +69,12 @@ import { HistoricalDataItem, PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; +import { PortfolioCalculator } from './portfolio-calculator'; import { RulesService } from './rules.service'; +const developedMarkets = require('../../assets/countries/developed-markets.json'); +const emergingMarkets = require('../../assets/countries/emerging-markets.json'); + @Injectable() export class PortfolioService { public constructor( @@ -159,15 +164,18 @@ export class PortfolioService { ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - this.request.user.Settings.currency - ); + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId, + includeDrafts: true + }); - const { transactionPoints } = await this.getTransactionPoints({ - userId, - includeDrafts: true + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders }); + portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { return []; @@ -208,12 +216,17 @@ export class PortfolioService { ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - this.request.user.Settings.currency - ); + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); - const { transactionPoints } = await this.getTransactionPoints({ userId }); portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { return { @@ -302,13 +315,16 @@ export class PortfolioService { this.request.user?.Settings?.currency ?? user.Settings?.currency ?? baseCurrency; - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - userCurrency - ); - const { orders, transactionPoints } = await this.getTransactionPoints({ - userId + const { orders, portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders }); portfolioCalculator.setTransactionPoints(transactionPoints); @@ -368,7 +384,31 @@ export class PortfolioService { const value = item.quantity.mul(item.marketPrice); const symbolProfile = symbolProfileMap[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol]; + + const markets: { [key in Market]: number } = { + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }; + + for (const country of symbolProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .plus(country.weight) + .toNumber(); + } + } + holdings[item.symbol] = { + markets, allocationCurrent: value.div(totalValue).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(), assetClass: symbolProfile.assetClass, @@ -474,11 +514,13 @@ export class PortfolioService { unitPrice: new Big(order.unitPrice) })); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - positionCurrency - ); - portfolioCalculator.computeTransactionPoints(portfolioOrders); + const portfolioCalculator = new PortfolioCalculator({ + currency: positionCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.computeTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints(); const portfolioStart = parseDate(transactionPoints[0].date); @@ -657,12 +699,16 @@ export class PortfolioService { ): Promise<{ hasErrors: boolean; positions: Position[] }> { const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - this.request.user.Settings.currency - ); + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); - const { transactionPoints } = await this.getTransactionPoints({ userId }); + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); if (transactionPoints?.length <= 0) { return { @@ -730,18 +776,21 @@ export class PortfolioService { ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - this.request.user.Settings.currency - ); + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); - const { transactionPoints } = await this.getTransactionPoints({ userId }); + const portfolioCalculator = new PortfolioCalculator({ + 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, @@ -760,26 +809,34 @@ export class PortfolioService { ); 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(); + const currentGrossPerformance = currentPositions.grossPerformance; + let currentGrossPerformancePercent = + currentPositions.grossPerformancePercentage; + const currentNetPerformance = currentPositions.netPerformance; + let currentNetPerformancePercent = + currentPositions.netPerformancePercentage; + + if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) { + // If algebraic sign is different, harmonize it + currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1); + } + + if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) { + // If algebraic sign is different, harmonize it + currentNetPerformancePercent = currentNetPerformancePercent.mul(-1); + } return { + errors: currentPositions.errors, hasErrors: currentPositions.hasErrors || hasErrors, performance: { - annualizedPerformancePercent, - currentGrossPerformance, - currentGrossPerformancePercent, - currentNetPerformance, - currentNetPerformancePercent, - currentValue + currentValue, + currentGrossPerformance: currentGrossPerformance.toNumber(), + currentGrossPerformancePercent: + currentGrossPerformancePercent.toNumber(), + currentNetPerformance: currentNetPerformance.toNumber(), + currentNetPerformancePercent: currentNetPerformancePercent.toNumber() } }; } @@ -788,9 +845,10 @@ export class PortfolioService { const currency = this.request.user.Settings.currency; const userId = await this.getUserId(impersonationId, this.request.user.id); - const { orders, transactionPoints } = await this.getTransactionPoints({ - userId - }); + const { orders, portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); if (isEmpty(orders)) { return { @@ -798,10 +856,12 @@ export class PortfolioService { }; } - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - currency - ); + const portfolioCalculator = new PortfolioCalculator({ + currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); @@ -907,8 +967,24 @@ export class PortfolioService { .plus(items) .toNumber(); + const daysInMarket = differenceInDays(new Date(), firstOrderDate); + + const annualizedPerformancePercent = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: [] + }) + .getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercent: new Big( + performanceInformation.performance.currentNetPerformancePercent + ) + }) + ?.toNumber(); + return { ...performanceInformation.performance, + annualizedPerformancePercent, cash, dividend, fees, @@ -917,8 +993,6 @@ export class PortfolioService { netWorth, totalBuy, totalSell, - annualizedPerformancePercent: - performanceInformation.performance.annualizedPerformancePercent, committedFunds: committedFunds.toNumber(), emergencyFund: emergencyFund.toNumber(), ordersCount: orders.filter((order) => { @@ -937,8 +1011,8 @@ export class PortfolioService { cashDetails: CashDetails; emergencyFund: Big; investment: Big; - userCurrency: string; value: Big; + userCurrency: string; }) { const cashPositions: PortfolioDetails['holdings'] = {}; @@ -1111,6 +1185,7 @@ export class PortfolioService { }): Promise<{ transactionPoints: TransactionPoint[]; orders: OrderWithAccount[]; + portfolioOrders: PortfolioOrder[]; }> { const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; @@ -1122,7 +1197,7 @@ export class PortfolioService { }); if (orders.length <= 0) { - return { transactionPoints: [], orders: [] }; + return { transactionPoints: [], orders: [], portfolioOrders: [] }; } const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ @@ -1149,14 +1224,18 @@ export class PortfolioService { ) })); - const portfolioCalculator = new PortfolioCalculator( - this.currentRateService, - userCurrency - ); - portfolioCalculator.computeTransactionPoints(portfolioOrders); + const portfolioCalculator = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.computeTransactionPoints(); + return { transactionPoints: portfolioCalculator.getTransactionPoints(), - orders + orders, + portfolioOrders }; } diff --git a/apps/api/src/app/user/interfaces/user-settings.interface.ts b/apps/api/src/app/user/interfaces/user-settings.interface.ts index ef3b03f1..8f887807 100644 --- a/apps/api/src/app/user/interfaces/user-settings.interface.ts +++ b/apps/api/src/app/user/interfaces/user-settings.interface.ts @@ -1,6 +1,5 @@ export interface UserSettings { emergencyFund?: number; locale?: string; - isNewCalculationEngine?: boolean; isRestrictedView?: boolean; } diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index eaa41464..b09e904d 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -5,10 +5,6 @@ export class UpdateUserSettingDto { @IsOptional() emergencyFund?: number; - @IsBoolean() - @IsOptional() - isNewCalculationEngine?: boolean; - @IsBoolean() @IsOptional() isRestrictedView?: boolean; diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 072d9148..743d9c56 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -222,24 +222,6 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } - public onNewCalculationChange(aEvent: MatSlideToggleChange) { - this.dataService - .putUserSetting({ isNewCalculationEngine: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - public onRedeemCoupon() { let couponCode = prompt('Please enter your coupon code:'); couponCode = couponCode?.trim(); diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index f1993a06..97af7d21 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -169,23 +169,6 @@ > -
-
-
New Calculation Engine
-
Experimental
-
-
- -
-