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>
This commit is contained in:
parent
ed731afc66
commit
9821b7f8f0
@ -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 { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
|
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
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 { Currency } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
@ -35,13 +35,23 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
}
|
}
|
||||||
} else if (symbol === 'AMZN') {
|
} else if (symbol === 'AMZN') {
|
||||||
return { marketPrice: 2021.99 };
|
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') {
|
} else if (symbol === 'TSLA') {
|
||||||
if (isSameDay(parse('2021-07-26', DATE_FORMAT, new Date()), date)) {
|
if (isSameDay(parse('2021-07-26', DATE_FORMAT, new Date()), date)) {
|
||||||
return { marketPrice: 657.62 };
|
return { marketPrice: 657.62 };
|
||||||
}
|
} else if (isSameDay(parse('2021-01-02', DATE_FORMAT, new Date()), date)) {
|
||||||
if (isSameDay(parse('2021-01-02', DATE_FORMAT, new Date()), date)) {
|
|
||||||
return { marketPrice: 666.66 };
|
return { marketPrice: 666.66 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
} else {
|
} else {
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
@ -726,7 +736,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
// see next test for details about how to calculate this
|
// see next test for details about how to calculate this
|
||||||
grossPerformance: new Big('240.4'),
|
grossPerformance: new Big('240.4'),
|
||||||
grossPerformancePercentage: new Big(
|
grossPerformancePercentage: new Big(
|
||||||
'0.349632913145865078264579821060810370805662039085569533288730749607797361322474717934042420125015808'
|
'0.0883940790487647710162214425767848424215253864940558186258745429269647266073266478435285352186572448'
|
||||||
),
|
),
|
||||||
investment: new Big('4460.95'),
|
investment: new Big('4460.95'),
|
||||||
marketPrice: 194.86,
|
marketPrice: 194.86,
|
||||||
@ -781,11 +791,12 @@ describe('PortfolioCalculator', () => {
|
|||||||
.mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
|
.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-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-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: 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(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
parse('2020-01-01', DATE_FORMAT, new Date())
|
parse('2020-01-01', DATE_FORMAT, new Date())
|
||||||
@ -805,7 +816,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
transactionCount: 2,
|
transactionCount: 2,
|
||||||
grossPerformance: new Big('303.2'),
|
grossPerformance: new Big('303.2'),
|
||||||
grossPerformancePercentage: new Big(
|
grossPerformancePercentage: new Big(
|
||||||
'0.1388661601402688486251911721754180022242'
|
'0.2753783814827239834392742298083677500037'
|
||||||
),
|
),
|
||||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||||
currency: 'USD'
|
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', () => {
|
describe('calculate timeline', () => {
|
||||||
|
@ -195,6 +195,8 @@ export class PortfolioCalculator {
|
|||||||
firstIndex--;
|
firstIndex--;
|
||||||
}
|
}
|
||||||
const invalidSymbols = [];
|
const invalidSymbols = [];
|
||||||
|
const lastInvestments: { [symbol: string]: Big } = {};
|
||||||
|
const lastQuantities: { [symbol: string]: Big } = {};
|
||||||
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||||
const currentDate =
|
const currentDate =
|
||||||
i === firstIndex ? startString : this.transactionPoints[i].date;
|
i === firstIndex ? startString : this.transactionPoints[i].date;
|
||||||
@ -217,11 +219,21 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let currentValue = marketSymbolMap[currentDate]?.[item.symbol];
|
let lastInvestment: Big = new Big(0);
|
||||||
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
let lastQuantity: Big = item.quantity;
|
||||||
currentValue = item.investment.div(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);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
console.error(
|
||||||
@ -229,18 +241,25 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
continue;
|
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];
|
let oldGrossPerformance = grossPerformance[item.symbol];
|
||||||
if (!oldGrossPerformance) {
|
if (!oldGrossPerformance) {
|
||||||
oldGrossPerformance = new Big(0);
|
oldGrossPerformance = new Big(0);
|
||||||
}
|
}
|
||||||
grossPerformance[item.symbol] = oldGrossPerformance.plus(
|
const currentPerformance = endValue.minus(investedValue);
|
||||||
marketSymbolMap[nextDate][item.symbol]
|
grossPerformance[item.symbol] =
|
||||||
.minus(currentValue)
|
oldGrossPerformance.plus(currentPerformance);
|
||||||
.mul(item.quantity)
|
|
||||||
);
|
lastInvestments[item.symbol] = item.investment;
|
||||||
|
lastQuantities[item.symbol] = item.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user