diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts index 5410c5f85..61ef36624 100644 --- a/apps/api/src/app/core/portfolio-calculator.spec.ts +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -626,23 +626,25 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - // eslint-disable-next-line @typescript-eslint/naming-convention - VTI: { - averagePrice: new Big('178.438'), - currency: 'USD', - firstBuyDate: '2019-02-01', - // see next test for details about how to calculate this - grossPerformance: new Big('265.2'), - grossPerformancePercentage: new Big( - '0.37322057787174066244232522865731355471028555367747465860626740684417274277219590953836818016777856' - ), - investment: new Big('4460.95'), - marketPrice: 194.86, - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - quantity: new Big('25'), - symbol: 'VTI', - transactionCount: 5 - } + hasErrors: false, + positions: [ + { + averagePrice: new Big('178.438'), + currency: 'USD', + firstBuyDate: '2019-02-01', + // see next test for details about how to calculate this + grossPerformance: new Big('265.2'), + grossPerformancePercentage: new Big( + '0.37322057787174066244232522865731355471028555367747465860626740684417274277219590953836818016777856' + ), + investment: new Big('4460.95'), + marketPrice: 194.86, + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + quantity: new Big('25'), + symbol: 'VTI', + transactionCount: 5 + } + ] }); }); @@ -700,21 +702,24 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - VTI: { - averagePrice: new Big('146.185'), - firstBuyDate: '2019-02-01', - quantity: new Big('20'), - symbol: 'VTI', - investment: new Big('2923.7'), - marketPrice: 194.86, - transactionCount: 2, - grossPerformance: new Big('303.2'), - grossPerformancePercentage: new Big( - '0.1388661601402688486251911721754180022242' - ), - name: 'Vanguard Total Stock Market Index Fund ETF Shares', - currency: 'USD' - } + hasErrors: false, + positions: [ + { + averagePrice: new Big('146.185'), + firstBuyDate: '2019-02-01', + quantity: new Big('20'), + symbol: 'VTI', + investment: new Big('2923.7'), + marketPrice: 194.86, + transactionCount: 2, + grossPerformance: new Big('303.2'), + grossPerformancePercentage: new Big( + '0.1388661601402688486251911721754180022242' + ), + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + currency: 'USD' + } + ] }); }); }); diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index 7b7a9091c..af2d5c9be 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -106,16 +106,19 @@ export class PortfolioCalculator { } public async getCurrentPositions(start: Date): Promise<{ - [symbol: string]: TimelinePosition; + hasErrors: boolean; + positions: TimelinePosition[]; }> { if (!this.transactionPoints?.length) { - return {}; + return { + hasErrors: false, + positions: [] + }; } const lastTransactionPoint = this.transactionPoints[this.transactionPoints.length - 1]; - const result: { [symbol: string]: TimelinePosition } = {}; // use Date.now() to use the mock for today const today = new Date(Date.now()); @@ -171,6 +174,7 @@ export class PortfolioCalculator { ); } + let hasErrors = false; const startString = format(start, DATE_FORMAT); const holdingPeriodReturns: { [symbol: string]: Big } = {}; @@ -178,12 +182,14 @@ export class PortfolioCalculator { let todayString = format(today, DATE_FORMAT); // in case no symbols are there for today, use yesterday if (!marketSymbolMap[todayString]) { + hasErrors = true; todayString = format(subDays(today, 1), DATE_FORMAT); } if (firstIndex > 0) { firstIndex--; } + const invalidSymbols = []; for (let i = firstIndex; i < this.transactionPoints.length; i++) { const currentDate = i === firstIndex ? startString : this.transactionPoints[i].date; @@ -198,6 +204,22 @@ export class PortfolioCalculator { if (!oldHoldingPeriodReturn) { oldHoldingPeriodReturn = new Big(1); } + if (!marketSymbolMap[nextDate]?.[item.symbol]) { + invalidSymbols.push(item.symbol); + hasErrors = true; + console.error( + `Missing value for symbol ${item.symbol} at ${nextDate}` + ); + continue; + } + if (!marketSymbolMap[currentDate]?.[item.symbol]) { + invalidSymbols.push(item.symbol); + hasErrors = true; + console.error( + `Missing value for symbol ${item.symbol} at ${currentDate}` + ); + continue; + } holdingPeriodReturns[item.symbol] = oldHoldingPeriodReturn.mul( marketSymbolMap[nextDate][item.symbol].div( marketSymbolMap[currentDate][item.symbol] @@ -215,26 +237,31 @@ export class PortfolioCalculator { } } + const positions: TimelinePosition[] = []; for (const item of lastTransactionPoint.items) { - const marketValue = marketSymbolMap[todayString][item.symbol]; - result[item.symbol] = { + const marketValue = marketSymbolMap[todayString]?.[item.symbol]; + const isValid = invalidSymbols.indexOf(item.symbol) === -1; + positions.push({ averagePrice: item.investment.div(item.quantity), currency: item.currency, firstBuyDate: item.firstBuyDate, - grossPerformance: grossPerformance[item.symbol] ?? null, - grossPerformancePercentage: holdingPeriodReturns[item.symbol] - ? holdingPeriodReturns[item.symbol].minus(1) + grossPerformance: isValid + ? grossPerformance[item.symbol] ?? null : null, + grossPerformancePercentage: + isValid && holdingPeriodReturns[item.symbol] + ? holdingPeriodReturns[item.symbol].minus(1) + : null, investment: item.investment, - marketPrice: marketValue.toNumber(), + marketPrice: marketValue?.toNumber() ?? null, name: item.name, quantity: item.quantity, symbol: item.symbol, transactionCount: item.transactionCount - }; + }); } - return result; + return { hasErrors, positions }; } public async calculateTimeline( diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 2015e55a1..cc53c5351 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -31,7 +31,7 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { Response } from 'express'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { HistoricalDataItem, @@ -280,12 +280,7 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId, @Query('range') range ): Promise { - const positions = await this.portfolioService.getPositions( - impersonationId, - range - ); - - return { positions }; + return await this.portfolioService.getPositions(impersonationId, range); } @Get('position/:symbol') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index c0960c0f4..9fab3729e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -397,7 +397,7 @@ export class PortfolioService { public async getPositions( aImpersonationId: string, aDateRange: DateRange = 'max' - ): Promise { + ): Promise<{ hasErrors: boolean; positions: Position[] }> { const impersonationUserId = await this.impersonationService.validateImpersonationId( aImpersonationId, @@ -417,23 +417,27 @@ export class PortfolioService { const portfolioStart = parseDate(transactionPoints[0].date); const startDate = this.getStartDate(aDateRange, portfolioStart); - const positions = await portfolioCalculator.getCurrentPositions(startDate); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); - return Object.values(positions).map((position) => { - return { - ...position, - averagePrice: new Big(position.averagePrice).toNumber(), - grossPerformance: new Big(position.grossPerformance).toNumber(), - grossPerformancePercentage: new Big( - position.grossPerformancePercentage - ).toNumber(), - investment: new Big(position.investment).toNumber(), - name: position.name, - quantity: new Big(position.quantity).toNumber(), - type: Type.Unknown, // TODO - url: '' // TODO - }; - }); + return { + hasErrors: currentPositions.hasErrors, + positions: currentPositions.positions.map((position) => { + return { + ...position, + averagePrice: new Big(position.averagePrice).toNumber(), + grossPerformance: position.grossPerformance?.toNumber() ?? null, + grossPerformancePercentage: + position.grossPerformancePercentage?.toNumber() ?? null, + investment: new Big(position.investment).toNumber(), + name: position.name, + quantity: new Big(position.quantity).toNumber(), + type: Type.Unknown, // TODO + url: '' // TODO + }; + }) + }; } private getStartDate(aDateRange: DateRange, portfolioStart: Date) {