diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts index f4d5173db..fc1776a98 100644 --- a/apps/api/src/app/core/portfolio-calculator.spec.ts +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -7,7 +7,7 @@ import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timel import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface'; import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator'; import { OrderType } from '@ghostfolio/api/models/order-type'; -import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { Currency } from '@prisma/client'; import Big from 'big.js'; import { @@ -35,13 +35,23 @@ function mockGetValue(symbol: string, date: Date) { } } else if (symbol === 'AMZN') { return { marketPrice: 2021.99 }; + } else if (symbol === '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 }; } else if (symbol === 'TSLA') { if (isSameDay(parse('2021-07-26', DATE_FORMAT, new Date()), date)) { return { marketPrice: 657.62 }; - } - if (isSameDay(parse('2021-01-02', DATE_FORMAT, new Date()), date)) { + } else if (isSameDay(parse('2021-01-02', DATE_FORMAT, new Date()), date)) { return { marketPrice: 666.66 }; } + return { marketPrice: 0 }; } else { return { marketPrice: 0 }; @@ -726,7 +736,7 @@ describe('PortfolioCalculator', () => { // see next test for details about how to calculate this grossPerformance: new Big('240.4'), grossPerformancePercentage: new Big( - '0.349632913145865078264579821060810370805662039085569533288730749607797361322474717934042420125015808' + '0.0883940790487647710162214425767848424215253864940558186258745429269647266073266478435285352186572448' ), investment: new Big('4460.95'), marketPrice: 194.86, @@ -781,11 +791,12 @@ describe('PortfolioCalculator', () => { .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 - 1 = 0.100526008 + // 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 - // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/3766=1.034838024 - 1 = 0.034838024 + // 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.034838024 = 1.138866159 => 13.89 % + // gross performance percentage: 1.100526008 * 1.158880728 = 1.275378381 => 27.5378381 % const currentPositions = await portfolioCalculator.getCurrentPositions( parse('2020-01-01', DATE_FORMAT, new Date()) @@ -805,7 +816,7 @@ describe('PortfolioCalculator', () => { transactionCount: 2, grossPerformance: new Big('303.2'), grossPerformancePercentage: new Big( - '0.1388661601402688486251911721754180022242' + '0.2753783814827239834392742298083677500037' ), name: 'Vanguard Total Stock Market Index Fund ETF Shares', currency: 'USD' @@ -813,6 +824,74 @@ describe('PortfolioCalculator', () => { ] }); }); + + /** + * Source: https://www.investopedia.com/terms/t/time-weightedror.asp + */ + it('with TWR example from Investopedia: Scenario 1', async () => { + const portfolioCalculator = new PortfolioCalculator( + currentRateService, + Currency.USD + ); + portfolioCalculator.setTransactionPoints([ + { + date: '2010-12-31', + items: [ + { + name: 'Mutual Fund A', + quantity: new Big('1000000'), // 1 million + symbol: 'MFA', + investment: new Big('1000000'), // 1 million + currency: Currency.USD, + firstBuyDate: '2010-12-31', + transactionCount: 1 + } + ] + }, + { + date: '2011-08-15', + items: [ + { + name: 'Mutual Fund A', + quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484 + symbol: 'MFA', + investment: new Big('1100000'), // 1,000,000 + 100,000 + currency: Currency.USD, + firstBuyDate: '2010-12-31', + 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({ + hasErrors: false, + positions: [ + { + 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 % + name: 'Mutual Fund A', + currency: 'USD' + } + ] + }); + }); }); describe('calculate timeline', () => { diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index e5319f231..bcba3df54 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -195,6 +195,8 @@ export class PortfolioCalculator { firstIndex--; } const invalidSymbols = []; + const lastInvestments: { [symbol: string]: Big } = {}; + const lastQuantities: { [symbol: string]: Big } = {}; for (let i = firstIndex; i < this.transactionPoints.length; i++) { const currentDate = i === firstIndex ? startString : this.transactionPoints[i].date; @@ -217,11 +219,21 @@ export class PortfolioCalculator { ); continue; } - let currentValue = marketSymbolMap[currentDate]?.[item.symbol]; + 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); if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) { - currentValue = item.investment.div(item.quantity); + initialValue = item.investment; + investedValue = item.investment; } - if (!currentValue) { + if (!initialValue) { invalidSymbols.push(item.symbol); hasErrors = true; console.error( @@ -229,18 +241,25 @@ export class PortfolioCalculator { ); continue; } - holdingPeriodReturns[item.symbol] = oldHoldingPeriodReturn.mul( - marketSymbolMap[nextDate][item.symbol].div(currentValue) + + const cashFlow = lastInvestment; + const endValue = marketSymbolMap[nextDate][item.symbol].mul( + item.quantity ); + + const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow)); + holdingPeriodReturns[item.symbol] = + oldHoldingPeriodReturn.mul(holdingPeriodReturn); let oldGrossPerformance = grossPerformance[item.symbol]; if (!oldGrossPerformance) { oldGrossPerformance = new Big(0); } - grossPerformance[item.symbol] = oldGrossPerformance.plus( - marketSymbolMap[nextDate][item.symbol] - .minus(currentValue) - .mul(item.quantity) - ); + const currentPerformance = endValue.minus(investedValue); + grossPerformance[item.symbol] = + oldGrossPerformance.plus(currentPerformance); + + lastInvestments[item.symbol] = item.investment; + lastQuantities[item.symbol] = item.quantity; } }