From 809ee97f6f31546cbdaf7eea04dff1f0a570007a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 14 Feb 2022 18:50:47 +0100 Subject: [PATCH] Add tests (#693) --- .../portfolio/current-rate.service.mock.ts | 60 + ...o-calculator-new-baln-buy-and-sell.spec.ts | 95 ++ .../portfolio-calculator-new-baln-buy.spec.ts | 84 ++ ...portfolio-calculator-new-no-orders.spec.ts | 56 + .../app/portfolio/portfolio-calculator-new.ts | 1028 ++++++++--------- package.json | 1 + 6 files changed, 810 insertions(+), 514 deletions(-) create mode 100644 apps/api/src/app/portfolio/current-rate.service.mock.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts new file mode 100644 index 000000000..124a27f45 --- /dev/null +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -0,0 +1,60 @@ +import { parseDate, resetHours } from '@ghostfolio/common/helper'; +import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; + +import { GetValuesParams } from './interfaces/get-values-params.interface'; + +function mockGetValue(symbol: string, date: Date) { + switch (symbol) { + 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: 148.9 }; + } + + return { marketPrice: 0 }; + + default: + return { marketPrice: 0 }; + } +} + +export const CurrentRateServiceMock = { + 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); + } +}; 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-new-baln-buy-and-sell.spec.ts new file mode 100644 index 000000000..8906431fb --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy-and-sell.spec.ts @@ -0,0 +1,95 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { parseDate } from '@ghostfolio/common/helper'; +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculatorNew } from './portfolio-calculator-new'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +describe('PortfolioCalculatorNew', () => { + let currentRateService: CurrentRateService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell', async () => { + const portfolioCalculatorNew = new PortfolioCalculatorNew({ + currentRateService, + currency: 'CHF', + orders: [ + { + currency: 'CHF', + date: '2021-11-22', + dataSource: 'YAHOO', + fee: new Big(1.55), + name: 'Bâloise Holding AG', + quantity: new Big(2), + symbol: 'BALN.SW', + type: 'BUY', + unitPrice: new Big(142.9) + }, + { + currency: 'CHF', + date: '2021-11-30', + dataSource: 'YAHOO', + fee: new Big(1.65), + name: 'Bâloise Holding AG', + quantity: new Big(2), + symbol: 'BALN.SW', + type: 'SELL', + unitPrice: new Big(136.6) + } + ] + }); + + portfolioCalculatorNew.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + parseDate('2021-11-22') + ); + + spy.mockRestore(); + + expect(currentPositions).toEqual({ + currentValue: new Big('0'), + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.0440867739678096571'), + hasErrors: false, + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + firstBuyDate: '2021-11-22', + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.0440867739678096571'), + investment: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + marketPrice: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + transactionCount: 2 + } + ], + totalInvestment: new Big('0') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts new file mode 100644 index 000000000..230fb04ab --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-new-baln-buy.spec.ts @@ -0,0 +1,84 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { parseDate } from '@ghostfolio/common/helper'; +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculatorNew } from './portfolio-calculator-new'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +describe('PortfolioCalculatorNew', () => { + let currentRateService: CurrentRateService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy', async () => { + const portfolioCalculatorNew = new PortfolioCalculatorNew({ + currentRateService, + currency: 'CHF', + orders: [ + { + currency: 'CHF', + date: '2021-11-30', + dataSource: 'YAHOO', + fee: new Big(1.55), + name: 'Bâloise Holding AG', + quantity: new Big(2), + symbol: 'BALN.SW', + type: 'BUY', + unitPrice: new Big(136.6) + } + ] + }); + + portfolioCalculatorNew.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + parseDate('2021-11-30') + ); + + spy.mockRestore(); + + expect(currentPositions).toEqual({ + currentValue: new Big('297.8'), + grossPerformance: new Big('24.6'), + grossPerformancePercentage: new Big('0.09004392386530014641'), + hasErrors: false, + netPerformance: new Big('23.05'), + netPerformancePercentage: new Big('0.08437042459736456808'), + positions: [ + { + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + firstBuyDate: '2021-11-30', + grossPerformance: new Big('24.6'), + grossPerformancePercentage: new Big('0.09004392386530014641'), + investment: new Big('273.2'), + netPerformance: new Big('23.05'), + netPerformancePercentage: new Big('0.08437042459736456808'), + marketPrice: 148.9, + quantity: new Big('2'), + symbol: 'BALN.SW', + transactionCount: 1 + } + ], + totalInvestment: new Big('273.2') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts new file mode 100644 index 000000000..41e2ca381 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-new-no-orders.spec.ts @@ -0,0 +1,56 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { parseDate } from '@ghostfolio/common/helper'; +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculatorNew } from './portfolio-calculator-new'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +describe('PortfolioCalculatorNew', () => { + let currentRateService: CurrentRateService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null); + }); + + describe('get current positions', () => { + it('with no orders', async () => { + const portfolioCalculatorNew = new PortfolioCalculatorNew({ + currentRateService, + currency: 'CHF', + orders: [] + }); + + portfolioCalculatorNew.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const currentPositions = await portfolioCalculatorNew.getCurrentPositions( + new Date() + ); + + spy.mockRestore(); + + expect(currentPositions).toEqual({ + currentValue: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + hasErrors: false, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + positions: [], + totalInvestment: new Big(0) + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-new.ts b/apps/api/src/app/portfolio/portfolio-calculator-new.ts index ebf181885..83bbc663f 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-new.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-new.ts @@ -281,608 +281,608 @@ export class PortfolioCalculatorNew { }; } - public getSymbolMetrics({ - marketSymbolMap, - start, - symbol - }: { - marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - }; - start: Date; - symbol: string; - }) { - let orders: PortfolioOrderItem[] = this.orders.filter((order) => { - return order.symbol === symbol; - }); + public getInvestments(): { date: string; investment: Big }[] { + if (this.transactionPoints.length === 0) { + return []; + } - if (orders.length <= 0) { + return this.transactionPoints.map((transactionPoint) => { return { - hasErrors: false, - initialValue: new Big(0), - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0) + date: transactionPoint.date, + investment: transactionPoint.items.reduce( + (investment, transactionPointSymbol) => + investment.plus(transactionPointSymbol.investment), + 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)) - ) { + public async calculateTimeline( + timelineSpecification: TimelineSpecification[], + endDate: string + ): Promise { + if (timelineSpecification.length === 0) { return { - hasErrors: true, - initialValue: new Big(0), - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0) + maxNetPerformance: new Big(0), + minNetPerformance: new Big(0), + timelinePeriods: [] }; } - let feesAtStartDate = new Big(0); - let fees = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceAtStartDate = new Big(0); - let grossPerformanceFromSells = new Big(0); - let initialValue: Big; - let lastAveragePrice = new Big(0); - let lastTransactionInvestment = new Big(0); - let lastValueOfInvestmentBeforeTransaction = new Big(0); - let timeWeightedGrossPerformancePercentage = new Big(1); - let timeWeightedNetPerformancePercentage = new Big(1); - let totalInvestment = new Big(0); - let totalUnits = new Big(0); - - const holdingPeriodPerformances: { - grossReturn: Big; - netReturn: Big; - valueOfInvestment: 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 ?? new Big(0) - }); - - orders.push({ - symbol, - currency: null, - date: format(endDate, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - itemType: 'end', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtEndDate ?? new Big(0) - }); - - // Sort orders so that the start and end placeholder order are at the right - // position - orders = sortBy(orders, (order) => { - let sortIndex = new Date(order.date); - - if (order.itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); - } + const startDate = timelineSpecification[0].start; + const start = parseDate(startDate); + const end = parseDate(endDate); - if (order.itemType === 'end') { - sortIndex = addMilliseconds(sortIndex, 1); + 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++; } - - return sortIndex.getTime(); - }); - - const indexOfStartOrder = orders.findIndex((order) => { - return order.itemType === 'start'; - }); - - for (let i = 0; i < orders.length; i += 1) { - const order = orders[i]; - - const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPrice - ); - - const transactionInvestment = order.quantity - .mul(order.unitPrice) - .mul(this.getFactor(order.type)); - - if ( - !initialValue && - order.itemType !== 'start' && - order.itemType !== 'end' + while ( + j + 1 < this.transactionPoints.length && + !isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate) ) { - initialValue = transactionInvestment; + j++; } - fees = fees.plus(order.fee); - - totalUnits = totalUnits.plus( - order.quantity.mul(this.getFactor(order.type)) + 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 valueOfInvestment = totalUnits.mul(order.unitPrice); + 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 grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) - : new Big(0); + const maxNetPerformance = timelineInfoInterfaces + .map((timelineInfo) => timelineInfo.maxNetPerformance) + .filter((performance) => performance !== null) + .reduce((maxPerformance, current) => { + if (maxPerformance.gt(current)) { + return maxPerformance; + } else { + return current; + } + }); - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell - ); + const timelinePeriods = timelineInfoInterfaces.map( + (timelineInfo) => timelineInfo.timelinePeriods + ); - totalInvestment = totalInvestment - .plus(transactionInvestment) - .plus(grossPerformanceFromSell); + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: flatten(timelinePeriods) + }; + } - lastAveragePrice = totalUnits.eq(0) - ? new Big(0) - : totalInvestment.div(totalUnits); + private calculateOverallPerformance( + positions: TimelinePosition[], + initialValues: { [p: string]: Big } + ) { + let hasErrors = false; + let currentValue = new Big(0); + let totalInvestment = new Big(0); + let grossPerformance = new Big(0); + let grossPerformancePercentage = new Big(0); + let netPerformance = new Big(0); + let netPerformancePercentage = new Big(0); + let completeInitialValue = new Big(0); - const newGrossPerformance = valueOfInvestment - .minus(totalInvestment) - .plus(grossPerformanceFromSells); + 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 ( - i > indexOfStartOrder && - !lastValueOfInvestmentBeforeTransaction - .plus(lastTransactionInvestment) - .eq(0) + currentPosition.grossPerformancePercentage && + initialValues[currentPosition.symbol] ) { - const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - .minus( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ) - .div( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ); + const currentInitialValue = initialValues[currentPosition.symbol]; + completeInitialValue = completeInitialValue.plus(currentInitialValue); + grossPerformancePercentage = grossPerformancePercentage.plus( + currentPosition.grossPerformancePercentage.mul(currentInitialValue) + ); + netPerformancePercentage = netPerformancePercentage.plus( + currentPosition.netPerformancePercentage.mul(currentInitialValue) + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}` + ); + hasErrors = true; + } + } - timeWeightedGrossPerformancePercentage = - timeWeightedGrossPerformancePercentage.mul( - new Big(1).plus(grossHoldingPeriodReturn) - ); + if (!completeInitialValue.eq(0)) { + grossPerformancePercentage = + grossPerformancePercentage.div(completeInitialValue); + netPerformancePercentage = + netPerformancePercentage.div(completeInitialValue); + } - const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - .minus(fees.minus(feesAtStartDate)) - .minus( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ) - .div( - lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) - ); + return { + currentValue, + grossPerformance, + grossPerformancePercentage, + hasErrors, + netPerformance, + netPerformancePercentage, + totalInvestment + }; + } - timeWeightedNetPerformancePercentage = - timeWeightedNetPerformancePercentage.mul( - new Big(1).plus(netHoldingPeriodReturn) - ); + private async getTimePeriodForDate( + j: number, + startDate: Date, + endDate: Date + ): Promise { + let investment: Big = new Big(0); + let fees: Big = new Big(0); - holdingPeriodPerformances.push({ - grossReturn: grossHoldingPeriodReturn, - netReturn: netHoldingPeriodReturn, - valueOfInvestment: lastValueOfInvestmentBeforeTransaction.plus( - lastTransactionInvestment - ) + 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); } - grossPerformance = newGrossPerformance; - - lastTransactionInvestment = transactionInvestment; - - lastValueOfInvestmentBeforeTransaction = - valueOfInvestmentBeforeTransaction; + let marketSymbols: GetValueObject[] = []; + if (dataGatheringItems.length > 0) { + try { + marketSymbols = await this.currentRateService.getValues({ + currencies, + dataGatheringItems, + dateQuery: { + gte: startDate, + lt: endOfDay(endDate) + }, + userCurrency: this.currency + }); + } catch (error) { + Logger.error( + `Failed to fetch info for date ${startDate} with exception`, + error + ); + return null; + } + } - if (order.itemType === 'start') { - feesAtStartDate = fees; - grossPerformanceAtStartDate = grossPerformance; + 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 + ); + } } } - timeWeightedGrossPerformancePercentage = - timeWeightedGrossPerformancePercentage.minus(1); - - timeWeightedNetPerformancePercentage = - timeWeightedNetPerformancePercentage.minus(1); - - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalNetPerformance = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - - let valueOfInvestmentSum = new Big(0); + 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; + } - for (const holdingPeriodPerformance of holdingPeriodPerformances) { - valueOfInvestmentSum = valueOfInvestmentSum.plus( - holdingPeriodPerformance.valueOfInvestment - ); + const result = { + grossPerformance, + investment, + netPerformance, + value, + date: currentDateAsString + }; + results.push(result); + } } - let totalWeightedGrossPerformance = new Big(0); - let totalWeightedNetPerformance = new Big(0); + return { + maxNetPerformance, + minNetPerformance, + timelinePeriods: results + }; + } - // Weight the holding period returns according to their value of investment - for (const holdingPeriodPerformance of holdingPeriodPerformances) { - totalWeightedGrossPerformance = totalWeightedGrossPerformance.plus( - holdingPeriodPerformance.grossReturn - .mul(holdingPeriodPerformance.valueOfInvestment) - .div(valueOfInvestmentSum) - ); + private getFactor(type: TypeOfOrder) { + let factor: number; - totalWeightedNetPerformance = totalWeightedNetPerformance.plus( - holdingPeriodPerformance.netReturn - .mul(holdingPeriodPerformance.valueOfInvestment) - .div(valueOfInvestmentSum) - ); + switch (type) { + case 'BUY': + factor = 1; + break; + case 'SELL': + factor = -1; + break; + default: + factor = 0; + break; } - return { - initialValue, - hasErrors: !initialValue || !unitPriceAtEndDate, - netPerformance: totalNetPerformance, - netPerformancePercentage: totalWeightedNetPerformance, - grossPerformance: totalGrossPerformance, - grossPerformancePercentage: totalWeightedGrossPerformance - }; + return factor; } - public getInvestments(): { date: string; investment: Big }[] { - if (this.transactionPoints.length === 0) { - return []; + 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); } + } - return this.transactionPoints.map((transactionPoint) => { - return { - date: transactionPoint.date, - investment: transactionPoint.items.reduce( - (investment, transactionPointSymbol) => - investment.plus(transactionPointSymbol.investment), - new Big(0) - ) - }; + 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; }); - } - public async calculateTimeline( - timelineSpecification: TimelineSpecification[], - endDate: string - ): Promise { - if (timelineSpecification.length === 0) { + if (orders.length <= 0) { return { - maxNetPerformance: new Big(0), - minNetPerformance: new Big(0), - timelinePeriods: [] + hasErrors: false, + initialValue: new Big(0), + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0) }; } - const startDate = timelineSpecification[0].start; - const start = parseDate(startDate); - const end = parseDate(endDate); + const dateOfFirstTransaction = new Date(first(orders).date); + const endDate = new Date(Date.now()); - 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++; - } + const unitPriceAtStartDate = + marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; - 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 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) + }; } - 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; - } - }); + let feesAtStartDate = new Big(0); + let fees = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceFromSells = new Big(0); + let initialValue: Big; + let lastAveragePrice = new Big(0); + let lastTransactionInvestment = new Big(0); + let lastValueOfInvestmentBeforeTransaction = new Big(0); + let timeWeightedGrossPerformancePercentage = new Big(1); + let timeWeightedNetPerformancePercentage = new Big(1); + let totalInvestment = new Big(0); + let totalUnits = new Big(0); - const maxNetPerformance = timelineInfoInterfaces - .map((timelineInfo) => timelineInfo.maxNetPerformance) - .filter((performance) => performance !== null) - .reduce((maxPerformance, current) => { - if (maxPerformance.gt(current)) { - return maxPerformance; - } else { - return current; - } - }); + const holdingPeriodPerformances: { + grossReturn: Big; + netReturn: Big; + valueOfInvestment: Big; + }[] = []; - const timelinePeriods = timelineInfoInterfaces.map( - (timelineInfo) => timelineInfo.timelinePeriods - ); + // Add a synthetic order at the start and the end date + orders.push({ + symbol, + currency: null, + date: format(start, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + itemType: 'start', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtStartDate ?? new Big(0) + }); - return { - maxNetPerformance, - minNetPerformance, - timelinePeriods: flatten(timelinePeriods) - }; - } + orders.push({ + symbol, + currency: null, + date: format(endDate, DATE_FORMAT), + dataSource: null, + fee: new Big(0), + itemType: 'end', + name: '', + quantity: new Big(0), + type: TypeOfOrder.BUY, + unitPrice: unitPriceAtEndDate ?? new Big(0) + }); - private calculateOverallPerformance( - positions: TimelinePosition[], - initialValues: { [p: string]: Big } - ) { - let hasErrors = false; - let currentValue = new Big(0); - let totalInvestment = new Big(0); - let grossPerformance = new Big(0); - let grossPerformancePercentage = new Big(0); - let netPerformance = new Big(0); - let netPerformancePercentage = new Big(0); - let completeInitialValue = new Big(0); + // 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); - for (const currentPosition of positions) { - if (currentPosition.marketPrice) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPrice).mul(currentPosition.quantity) - ); - } else { - hasErrors = true; + if (order.itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); } - 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 (order.itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); } + return sortIndex.getTime(); + }); + + const indexOfStartOrder = orders.findIndex((order) => { + return order.itemType === 'start'; + }); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + const valueOfInvestmentBeforeTransaction = totalUnits.mul( + order.unitPrice + ); + + const transactionInvestment = order.quantity + .mul(order.unitPrice) + .mul(this.getFactor(order.type)); + if ( - currentPosition.grossPerformancePercentage && - initialValues[currentPosition.symbol] + !initialValue && + order.itemType !== 'start' && + order.itemType !== 'end' ) { - const currentInitialValue = initialValues[currentPosition.symbol]; - completeInitialValue = completeInitialValue.plus(currentInitialValue); - grossPerformancePercentage = grossPerformancePercentage.plus( - currentPosition.grossPerformancePercentage.mul(currentInitialValue) - ); - netPerformancePercentage = netPerformancePercentage.plus( - currentPosition.netPerformancePercentage.mul(currentInitialValue) - ); - } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}` - ); - hasErrors = true; + initialValue = transactionInvestment; } - } - if (!completeInitialValue.eq(0)) { - grossPerformancePercentage = - grossPerformancePercentage.div(completeInitialValue); - netPerformancePercentage = - netPerformancePercentage.div(completeInitialValue); - } + fees = fees.plus(order.fee); + + totalUnits = totalUnits.plus( + order.quantity.mul(this.getFactor(order.type)) + ); + + const valueOfInvestment = totalUnits.mul(order.unitPrice); - return { - currentValue, - grossPerformance, - grossPerformancePercentage, - hasErrors, - netPerformance, - netPerformancePercentage, - totalInvestment - }; - } + const grossPerformanceFromSell = + order.type === TypeOfOrder.SELL + ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) + : new Big(0); - private async getTimePeriodForDate( - j: number, - startDate: Date, - endDate: Date - ): Promise { - let investment: Big = new Big(0); - let fees: Big = new Big(0); + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - if (j >= 0) { - const currencies: { [name: string]: string } = {}; - const dataGatheringItems: IDataGatheringItem[] = []; + totalInvestment = totalInvestment + .plus(transactionInvestment) + .plus(grossPerformanceFromSell); - 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); - } + lastAveragePrice = totalUnits.eq(0) + ? new Big(0) + : totalInvestment.div(totalUnits); - 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 + const newGrossPerformance = valueOfInvestment + .minus(totalInvestment) + .plus(grossPerformanceFromSells); + + if ( + i > indexOfStartOrder && + !lastValueOfInvestmentBeforeTransaction + .plus(lastTransactionInvestment) + .eq(0) + ) { + const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction + .minus( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ) + .div( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) ); - 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 + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.mul( + new Big(1).plus(grossHoldingPeriodReturn) ); - } - } - } - 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]) + const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction + .minus(fees.minus(feesAtStartDate)) + .minus( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + ) + .div( + lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) ); - } + + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.mul( + new Big(1).plus(netHoldingPeriodReturn) + ); + + holdingPeriodPerformances.push({ + grossReturn: grossHoldingPeriodReturn, + netReturn: netHoldingPeriodReturn, + valueOfInvestment: lastValueOfInvestmentBeforeTransaction.plus( + lastTransactionInvestment + ) + }); } - 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); + grossPerformance = newGrossPerformance; + + lastTransactionInvestment = transactionInvestment; + + lastValueOfInvestmentBeforeTransaction = + valueOfInvestmentBeforeTransaction; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + grossPerformanceAtStartDate = grossPerformance; } } - return { - maxNetPerformance, - minNetPerformance, - timelinePeriods: results - }; - } + timeWeightedGrossPerformancePercentage = + timeWeightedGrossPerformancePercentage.minus(1); - private getFactor(type: TypeOfOrder) { - let factor: number; + timeWeightedNetPerformancePercentage = + timeWeightedNetPerformancePercentage.minus(1); - switch (type) { - case 'BUY': - factor = 1; - break; - case 'SELL': - factor = -1; - break; - default: - factor = 0; - break; + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + let valueOfInvestmentSum = new Big(0); + + for (const holdingPeriodPerformance of holdingPeriodPerformances) { + valueOfInvestmentSum = valueOfInvestmentSum.plus( + holdingPeriodPerformance.valueOfInvestment + ); } - return factor; - } + let totalWeightedGrossPerformance = new Big(0); + let totalWeightedNetPerformance = new Big(0); - 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); + // Weight the holding period returns according to their value of investment + for (const holdingPeriodPerformance of holdingPeriodPerformances) { + totalWeightedGrossPerformance = totalWeightedGrossPerformance.plus( + holdingPeriodPerformance.grossReturn + .mul(holdingPeriodPerformance.valueOfInvestment) + .div(valueOfInvestmentSum) + ); + + totalWeightedNetPerformance = totalWeightedNetPerformance.plus( + holdingPeriodPerformance.netReturn + .mul(holdingPeriodPerformance.valueOfInvestment) + .div(valueOfInvestmentSum) + ); } + + return { + initialValue, + hasErrors: !initialValue || !unitPriceAtEndDate, + netPerformance: totalNetPerformance, + netPerformancePercentage: totalWeightedNetPerformance, + grossPerformance: totalGrossPerformance, + grossPerformancePercentage: totalWeightedGrossPerformance + }; } private isNextItemActive( diff --git a/package.json b/package.json index 0044211f0..cd8c0ec80 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "start:server": "nx serve api --watch", "start:storybook": "nx run ui:storybook", "test": "nx test", + "test:single": "nx test --test-file portfolio-calculator-new.spec.ts", "ts-node": "ts-node", "update": "nx migrate latest", "watch:server": "nx build api --watch",