From dfcf826b4f1eddfc5de439f3e539e8339d3dae7d Mon Sep 17 00:00:00 2001 From: Valentin Zickner <3200232+vzickner@users.noreply.github.com> Date: Sat, 7 Aug 2021 07:07:12 +0200 Subject: [PATCH] implement support for buy-sell(-buy) scenario (#262) Co-authored-by: Valentin Zickner --- .../src/app/core/portfolio-calculator.spec.ts | 378 +++++++++++++----- apps/api/src/app/core/portfolio-calculator.ts | 76 ++-- .../src/app/portfolio/portfolio.service.ts | 13 +- 3 files changed, 315 insertions(+), 152 deletions(-) diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts index 19e524789..e71c0b3ef 100644 --- a/apps/api/src/app/core/portfolio-calculator.spec.ts +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -396,115 +396,9 @@ describe('PortfolioCalculator', () => { const portfolioItemsAtTransactionPoints = portfolioCalculator.getTransactionPoints(); - expect(portfolioItemsAtTransactionPoints).toEqual([ - { - date: '2019-02-01', - items: [ - { - quantity: new Big('10'), - symbol: 'VTI', - investment: new Big('1443.8'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 1 - } - ] - }, - { - date: '2019-08-03', - items: [ - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 2 - } - ] - }, - { - date: '2019-09-01', - items: [ - { - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: Currency.USD, - firstBuyDate: '2019-09-01', - transactionCount: 1 - }, - { - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 2 - } - ] - }, - { - date: '2020-02-02', - items: [ - { - quantity: new Big('5'), - symbol: 'AMZN', - investment: new Big('10109.95'), - currency: Currency.USD, - firstBuyDate: '2019-09-01', - transactionCount: 1 - }, - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 3 - } - ] - }, - { - date: '2020-08-02', - items: [ - { - quantity: new Big('5'), - symbol: 'VTI', - investment: new Big('652.55'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 3 - } - ] - }, - { - date: '2021-02-01', - items: [ - { - quantity: new Big('15'), - symbol: 'VTI', - investment: new Big('2684.05'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 4 - } - ] - }, - { - date: '2021-08-01', - items: [ - { - quantity: new Big('25'), - symbol: 'VTI', - investment: new Big('4460.95'), - currency: Currency.USD, - firstBuyDate: '2019-02-01', - transactionCount: 5 - } - ] - } - ]); + expect(portfolioItemsAtTransactionPoints).toEqual( + transactionPointsBuyAndSell + ); }); it('with mixed symbols', () => { @@ -740,6 +634,138 @@ describe('PortfolioCalculator', () => { }); }); + it('with buy and sell', async () => { + const portfolioCalculator = new PortfolioCalculator( + currentRateService, + Currency.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({ + hasErrors: false, + currentValue: new Big('4871.5'), + grossPerformance: new Big('240.4'), + grossPerformancePercentage: new Big('0.01104605615757711361'), + totalInvestment: new Big('4460.95'), + positions: [ + { + 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 + }, + { + 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, + Currency.USD + ); + portfolioCalculator.setTransactionPoints([ + { + date: '2019-09-01', + items: [ + { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('805.9'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 1 + } + ] + }, + { + date: '2020-08-02', + items: [ + { + quantity: new Big('0'), + symbol: 'VTI', + investment: new Big('0'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 2 + } + ] + }, + { + date: '2021-02-01', + items: [ + { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('1013.9'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + 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({ + hasErrors: false, + currentValue: new Big('1086.7'), + grossPerformance: new Big('207.6'), + grossPerformancePercentage: new Big('0.2516103956224511062'), + totalInvestment: new Big('1013.9'), + positions: [ + { + 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, @@ -1776,3 +1802,137 @@ const ordersVTITransactionPoints: TransactionPoint[] = [ ] } ]; + +const transactionPointsBuyAndSell = [ + { + date: '2019-02-01', + items: [ + { + quantity: new Big('10'), + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 1 + } + ] + }, + { + date: '2019-08-03', + items: [ + { + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 2 + } + ] + }, + { + date: '2019-09-01', + items: [ + { + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 1 + }, + { + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 2 + } + ] + }, + { + date: '2020-02-02', + items: [ + { + quantity: new Big('5'), + symbol: 'AMZN', + investment: new Big('10109.95'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 1 + }, + { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 3 + } + ] + }, + { + date: '2020-08-02', + items: [ + { + quantity: new Big('0'), + symbol: 'AMZN', + investment: new Big('0'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 2 + }, + { + quantity: new Big('5'), + symbol: 'VTI', + investment: new Big('652.55'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 3 + } + ] + }, + { + date: '2021-02-01', + items: [ + { + quantity: new Big('0'), + symbol: 'AMZN', + investment: new Big('0'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 2 + }, + { + quantity: new Big('15'), + symbol: 'VTI', + investment: new Big('2684.05'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 4 + } + ] + }, + { + date: '2021-08-01', + items: [ + { + quantity: new Big('0'), + symbol: 'AMZN', + investment: new Big('0'), + currency: Currency.USD, + firstBuyDate: '2019-09-01', + transactionCount: 2 + }, + { + quantity: new Big('25'), + symbol: 'VTI', + investment: new Big('4460.95'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 5 + } + ] + } +]; diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index a90d4a057..91c9a8123 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -52,16 +52,19 @@ export class PortfolioCalculator { 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, firstBuyDate: oldAccumulatedSymbol.firstBuyDate, - investment: unitPrice - .mul(order.quantity) - .mul(factor) - .add(oldAccumulatedSymbol.investment), - quantity: order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity), + investment: newQuantity.eq(0) + ? new Big(0) + : unitPrice + .mul(order.quantity) + .mul(factor) + .add(oldAccumulatedSymbol.investment), + quantity: newQuantity, symbol: order.symbol, transactionCount: oldAccumulatedSymbol.transactionCount + 1 }; @@ -82,11 +85,7 @@ export class PortfolioCalculator { const newItems = items.filter( (transactionPointItem) => transactionPointItem.symbol !== order.symbol ); - if (!currentTransactionPointItem.quantity.eq(0)) { - newItems.push(currentTransactionPointItem); - } else { - delete symbols[order.symbol]; - } + newItems.push(currentTransactionPointItem); newItems.sort((a, b) => a.symbol.localeCompare(b.symbol)); if (lastDate !== currentDate || lastTransactionPoint === null) { lastTransactionPoint = { @@ -231,31 +230,32 @@ export class PortfolioCalculator { if (i === firstIndex || !initialValues[item.symbol]) { initialValues[item.symbol] = initialValue; } - if (!initialValue) { - invalidSymbols.push(item.symbol); - hasErrors = true; - console.error( - `Missing value for symbol ${item.symbol} at ${currentDate}` - ); - continue; - } + if (!item.quantity.eq(0)) { + if (!initialValue) { + invalidSymbols.push(item.symbol); + hasErrors = true; + console.error( + `Missing value for symbol ${item.symbol} at ${currentDate}` + ); + continue; + } - const cashFlow = lastInvestment; - const endValue = marketSymbolMap[nextDate][item.symbol].mul( - item.quantity - ); + 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); + const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow)); + holdingPeriodReturns[item.symbol] = + oldHoldingPeriodReturn.mul(holdingPeriodReturn); + let oldGrossPerformance = grossPerformance[item.symbol]; + if (!oldGrossPerformance) { + oldGrossPerformance = new Big(0); + } + const currentPerformance = endValue.minus(investedValue); + grossPerformance[item.symbol] = + oldGrossPerformance.plus(currentPerformance); } - const currentPerformance = endValue.minus(investedValue); - grossPerformance[item.symbol] = - oldGrossPerformance.plus(currentPerformance); - lastInvestments[item.symbol] = item.investment; lastQuantities[item.symbol] = item.quantity; } @@ -267,7 +267,9 @@ export class PortfolioCalculator { const marketValue = marketSymbolMap[todayString]?.[item.symbol]; const isValid = invalidSymbols.indexOf(item.symbol) === -1; positions.push({ - averagePrice: item.investment.div(item.quantity), + averagePrice: item.quantity.eq(0) + ? new Big(0) + : item.investment.div(item.quantity), currency: item.currency, firstBuyDate: item.firstBuyDate, grossPerformance: isValid @@ -398,7 +400,7 @@ export class PortfolioCalculator { grossPerformance = grossPerformance.plus( currentPosition.grossPerformance ); - } else { + } else if (!currentPosition.quantity.eq(0)) { hasErrors = true; } @@ -411,7 +413,7 @@ export class PortfolioCalculator { grossPerformancePercentage = grossPerformancePercentage.plus( currentPosition.grossPerformancePercentage.mul(currentInitialValue) ); - } else { + } else if (!currentPosition.quantity.eq(0)) { console.error( `Initial value is missing for symbol ${currentPosition.symbol}` ); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 71a08d1a3..55bf6c277 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -358,9 +358,9 @@ export class PortfolioService { (item) => item.symbol === aSymbol ); if (currentSymbol) { - currentAveragePrice = currentSymbol.investment - .div(currentSymbol.quantity) - .toNumber(); + currentAveragePrice = currentSymbol.quantity.eq(0) + ? 0 + : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); } historicalDataArray.push({ @@ -470,9 +470,10 @@ export class PortfolioService { startDate ); - const symbols = currentPositions.positions.map( - (position) => position.symbol + const positions = currentPositions.positions.filter( + (item) => !item.quantity.eq(0) ); + const symbols = positions.map((position) => position.symbol); const [dataProviderResponses, symbolProfiles] = await Promise.all([ this.dataProviderService.get(symbols), @@ -486,7 +487,7 @@ export class PortfolioService { return { hasErrors: currentPositions.hasErrors, - positions: currentPositions.positions.map((position) => { + positions: positions.map((position) => { return { ...position, averagePrice: new Big(position.averagePrice).toNumber(),