From 9821b7f8f0c4a7197824e2fd5a1b6feecb936e02 Mon Sep 17 00:00:00 2001
From: Thomas <4159106+dtslvr@users.noreply.github.com>
Date: Sat, 31 Jul 2021 09:10:27 +0200
Subject: [PATCH] Add TWR test scenario 1 (#236)

* Add TWR test scenario 1

* fix second transaction item

* change time-weighted rate algorithm

Co-authored-by: Valentin Zickner <valentin.zickner@flowable.com>
---
 .../src/app/core/portfolio-calculator.spec.ts | 95 +++++++++++++++++--
 apps/api/src/app/core/portfolio-calculator.ts | 41 +++++---
 2 files changed, 117 insertions(+), 19 deletions(-)

diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts
index f4d5173d..fc1776a9 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 e5319f23..bcba3df5 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];
-        if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
-          currentValue = item.investment.div(item.quantity);
+        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];
         }
-        if (!currentValue) {
+
+        const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
+        let initialValue = itemValue?.mul(lastQuantity);
+        let investedValue = itemValue?.mul(item.quantity);
+        if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
+          initialValue = item.investment;
+          investedValue = item.investment;
+        }
+        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;
       }
     }