diff --git a/apps/api/src/app/core/current-rate.service.spec.ts b/apps/api/src/app/core/current-rate.service.spec.ts index 477e5ef98..b46818e8b 100644 --- a/apps/api/src/app/core/current-rate.service.spec.ts +++ b/apps/api/src/app/core/current-rate.service.spec.ts @@ -106,8 +106,10 @@ describe('CurrentRateService', () => { expect( await currentRateService.getValues({ currencies: { AMZN: Currency.USD }, - dateRangeEnd: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), - dateRangeStart: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)), + dateQuery: { + lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), + gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) + }, symbols: ['AMZN'], userCurrency: Currency.CHF }) diff --git a/apps/api/src/app/core/current-rate.service.ts b/apps/api/src/app/core/current-rate.service.ts index c63326105..82fedcbbc 100644 --- a/apps/api/src/app/core/current-rate.service.ts +++ b/apps/api/src/app/core/current-rate.service.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Currency } from '@prisma/client'; import { isToday } from 'date-fns'; -import { MarketDataService } from './market-data.service'; +import { DateQuery, MarketDataService } from './market-data.service'; @Injectable() export class CurrentRateService { @@ -52,14 +52,12 @@ export class CurrentRateService { public async getValues({ currencies, - dateRangeEnd, - dateRangeStart, + dateQuery, symbols, userCurrency }: GetValuesParams): Promise { const marketData = await this.marketDataService.getRange({ - dateRangeEnd, - dateRangeStart, + dateQuery, symbols }); @@ -77,11 +75,7 @@ export class CurrentRateService { }); } - throw new Error( - `Values not found for symbols ${symbols.join(', ')} from ${resetHours( - dateRangeStart - )} to ${resetHours(dateRangeEnd)}` - ); + throw new Error(`Values not found for symbols ${symbols.join(', ')}`); } } @@ -93,8 +87,7 @@ export interface GetValueParams { } export interface GetValuesParams { - dateRangeEnd: Date; - dateRangeStart: Date; + dateQuery: DateQuery; symbols: string[]; currencies: { [symbol: string]: Currency }; userCurrency: Currency; diff --git a/apps/api/src/app/core/market-data.service.ts b/apps/api/src/app/core/market-data.service.ts index 7809ccf19..6b4a916a3 100644 --- a/apps/api/src/app/core/market-data.service.ts +++ b/apps/api/src/app/core/market-data.service.ts @@ -2,7 +2,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { resetHours } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; import { MarketData } from '@prisma/client'; -import { endOfDay } from 'date-fns'; @Injectable() export class MarketDataService { @@ -24,12 +23,10 @@ export class MarketDataService { } public async getRange({ - dateRangeEnd, - dateRangeStart, + dateQuery, symbols }: { - dateRangeEnd: Date; - dateRangeStart: Date; + dateQuery: DateQuery; symbols: string[]; }): Promise { return await this.prisma.marketData.findMany({ @@ -42,10 +39,7 @@ export class MarketDataService { } ], where: { - date: { - gte: dateRangeStart, - lt: endOfDay(dateRangeEnd) - }, + date: dateQuery, symbol: { in: symbols } @@ -53,3 +47,9 @@ export class MarketDataService { }); } } + +export interface DateQuery { + gte?: Date; + lt?: Date; + in?: Date[]; +} diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts index 41feb21da..dbf323c22 100644 --- a/apps/api/src/app/core/portfolio-calculator.spec.ts +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -77,23 +77,34 @@ jest.mock('@ghostfolio/api/app/core/current-rate.service', () => { }, getValues: ({ currencies, - dateRangeEnd, - dateRangeStart, + dateQuery, symbols, userCurrency }: GetValuesParams) => { const result = []; - for ( - let date = resetHours(dateRangeStart); - isBefore(date, endOfDay(dateRangeEnd)); - date = addDays(date, 1) - ) { - for (const symbol of symbols) { - result.push({ - date, - symbol, - marketPrice: mockGetValue(symbol, date).marketPrice - }); + if (dateQuery.lt) { + for ( + let date = resetHours(dateQuery.gte); + isBefore(date, endOfDay(dateQuery.lt)); + date = addDays(date, 1) + ) { + for (const symbol of symbols) { + result.push({ + date, + symbol, + marketPrice: mockGetValue(symbol, date).marketPrice + }); + } + } + } else { + for (const date of dateQuery.in) { + for (const symbol of symbols) { + result.push({ + date, + symbol, + marketPrice: mockGetValue(symbol, date).marketPrice + }); + } } } return Promise.resolve(result); @@ -605,7 +616,14 @@ describe('PortfolioCalculator', () => { Currency.USD ); portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints); - const currentPositions = await portfolioCalculator.getCurrentPositions(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => 1603490400000); // 2020-10-24 + const currentPositions = await portfolioCalculator.getCurrentPositions( + parse('2019-01-01', 'yyyy-MM-dd', new Date()) + ); + spy.mockRestore(); expect(currentPositions).toEqual({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -613,10 +631,13 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('178.438'), currency: 'USD', firstBuyDate: '2019-02-01', - grossPerformance: new Big('872.05'), // 213.32*25-4460.95 - grossPerformancePercentage: new Big('0.19548526659119694236'), // 872.05/4460.95 + // 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: 213.32, + marketPrice: 194.86, name: 'Vanguard Total Stock Market Index Fund ETF Shares', quantity: new Big('25'), symbol: 'VTI', @@ -624,6 +645,78 @@ describe('PortfolioCalculator', () => { } }); }); + + it('with performance since Jan 1st, 2020', async () => { + const portfolioCalculator = new PortfolioCalculator( + currentRateService, + Currency.USD + ); + const transactionPoints = [ + { + date: '2019-02-01', + items: [ + { + quantity: new Big('10'), + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + symbol: 'VTI', + investment: new Big('1443.8'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 1 + } + ] + }, + { + date: '2020-08-03', + items: [ + { + quantity: new Big('20'), + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + symbol: 'VTI', + investment: new Big('2923.7'), + currency: Currency.USD, + firstBuyDate: '2019-02-01', + transactionCount: 2 + } + ] + } + ]; + + portfolioCalculator.setTransactionPoints(transactionPoints); + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => 1603490400000); // 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 => 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 + // gross performance: 1883-1711 + 3897.2-3766 = 303.2 + // gross performance percentage: 1.100526008 * 1.034838024 = 1.138866159 => 13.89 % + + const currentPositions = await portfolioCalculator.getCurrentPositions( + parse('2020-01-01', 'yyyy-MM-dd', new Date()) + ); + + 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' + } + }); + }); }); describe('calculate timeline', () => { diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index 484516272..964a9e4fc 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -3,7 +3,7 @@ import { GetValueObject } from '@ghostfolio/api/app/core/current-rate.service'; import { OrderType } from '@ghostfolio/api/models/order-type'; -import { resetHours } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { Currency } from '@prisma/client'; import Big from 'big.js'; @@ -17,17 +17,10 @@ import { isBefore, max, min, - parse, subDays } from 'date-fns'; import { flatten } from 'lodash'; -const DATE_FORMAT = 'yyyy-MM-dd'; - -function dparse(date: string) { - return parse(date, DATE_FORMAT, new Date()); -} - export class PortfolioCalculator { private transactionPoints: TransactionPoint[]; @@ -115,7 +108,7 @@ export class PortfolioCalculator { return this.transactionPoints; } - public async getCurrentPositions(): Promise<{ + public async getCurrentPositions(start: Date): Promise<{ [symbol: string]: TimelinePosition; }> { if (!this.transactionPoints?.length) { @@ -126,29 +119,117 @@ export class PortfolioCalculator { this.transactionPoints[this.transactionPoints.length - 1]; const result: { [symbol: string]: TimelinePosition } = {}; - const marketValues = await this.getMarketValues( - lastTransactionPoint, - resetHours(subDays(new Date(), 3)), - endOfDay(new Date()) - ); + // use Date.now() to use the mock for today + const today = new Date(Date.now()); - for (const item of lastTransactionPoint.items) { - const marketValue = marketValues[item.symbol]; - const grossPerformance = marketValue - ? new Big(marketValue.marketPrice) + let firstTransactionPoint: TransactionPoint = null; + let firstIndex = this.transactionPoints.length; + const dates = []; + const symbols = new Set(); + const currencies: { [symbol: string]: Currency } = {}; + + dates.push(resetHours(start)); + for (const item of this.transactionPoints[firstIndex - 1].items) { + symbols.add(item.symbol); + currencies[item.symbol] = item.currency; + } + for (let i = 0; i < this.transactionPoints.length; i++) { + if ( + !isBefore(parseDate(this.transactionPoints[i].date), start) && + firstTransactionPoint === null + ) { + firstTransactionPoint = this.transactionPoints[i]; + firstIndex = i; + } + if (firstTransactionPoint !== null) { + dates.push(resetHours(parseDate(this.transactionPoints[i].date))); + } + } + + const yesterday = resetHours(subDays(today, 1)); + if (dates.indexOf(yesterday) === -1) { + dates.push(yesterday); + } + dates.push(resetHours(today)); + + const marketSymbols = await this.currentRateService.getValues({ + currencies, + dateQuery: { + in: dates + }, + symbols: Array.from(symbols), + userCurrency: this.currency + }); + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + + const startString = format(start, DATE_FORMAT); + + const holdingPeriodReturns: { [symbol: string]: Big } = {}; + const grossPerformance: { [symbol: string]: Big } = {}; + let todayString = format(today, DATE_FORMAT); + // in case no symbols are there for today, use yesterday + if (!marketSymbolMap[todayString]) { + todayString = format(subDays(today, 1), DATE_FORMAT); + } + + if (firstIndex > 0) { + firstIndex--; + } + for (let i = firstIndex; i < this.transactionPoints.length; i++) { + const currentDate = + i === firstIndex ? startString : this.transactionPoints[i].date; + const nextDate = + i + 1 < this.transactionPoints.length + ? this.transactionPoints[i + 1].date + : todayString; + + const items = this.transactionPoints[i].items; + for (const item of items) { + let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol]; + if (!oldHoldingPeriodReturn) { + oldHoldingPeriodReturn = new Big(1); + } + holdingPeriodReturns[item.symbol] = oldHoldingPeriodReturn.mul( + marketSymbolMap[nextDate][item.symbol].div( + marketSymbolMap[currentDate][item.symbol] + ) + ); + let oldGrossPerformance = grossPerformance[item.symbol]; + if (!oldGrossPerformance) { + oldGrossPerformance = new Big(0); + } + grossPerformance[item.symbol] = oldGrossPerformance.plus( + marketSymbolMap[nextDate][item.symbol] + .minus(marketSymbolMap[currentDate][item.symbol]) .mul(item.quantity) - .minus(item.investment) - : null; + ); + } + } + + for (const item of lastTransactionPoint.items) { + const marketValue = marketSymbolMap[todayString][item.symbol]; result[item.symbol] = { averagePrice: item.investment.div(item.quantity), currency: item.currency, firstBuyDate: item.firstBuyDate, - grossPerformance, - grossPerformancePercentage: marketValue - ? grossPerformance.div(item.investment) + grossPerformance: grossPerformance[item.symbol] ?? null, + grossPerformancePercentage: holdingPeriodReturns[item.symbol] + ? holdingPeriodReturns[item.symbol].minus(1) : null, investment: item.investment, - marketPrice: marketValue?.marketPrice, + marketPrice: marketValue.toNumber(), name: item.name, quantity: item.quantity, symbol: item.symbol, @@ -170,8 +251,8 @@ export class PortfolioCalculator { console.time('calculate-timeline-calculations'); const startDate = timelineSpecification[0].start; - const start = dparse(startDate); - const end = dparse(endDate); + const start = parseDate(startDate); + const end = parseDate(endDate); const timelinePeriodPromises: Promise[] = []; let i = 0; @@ -189,7 +270,7 @@ export class PortfolioCalculator { } while ( j + 1 < this.transactionPoints.length && - !isAfter(dparse(this.transactionPoints[j + 1].date), currentDate) + !isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate) ) { j++; } @@ -198,7 +279,7 @@ export class PortfolioCalculator { if (timelineSpecification[i].accuracy === 'day') { let nextEndDate = end; if (j + 1 < this.transactionPoints.length) { - nextEndDate = dparse(this.transactionPoints[j + 1].date); + nextEndDate = parseDate(this.transactionPoints[j + 1].date); } periodEndDate = min([ addMonths(currentDate, 3), @@ -242,8 +323,10 @@ export class PortfolioCalculator { currencies[item.symbol] = item.currency; } const values = await this.currentRateService.getValues({ - dateRangeStart, - dateRangeEnd, + dateQuery: { + gte: dateRangeStart, + lt: endOfDay(dateRangeEnd) + }, symbols, currencies, userCurrency: this.currency @@ -280,8 +363,10 @@ export class PortfolioCalculator { if (symbols.length > 0) { try { marketSymbols = await this.currentRateService.getValues({ - dateRangeStart: startDate, - dateRangeEnd: endDate, + dateQuery: { + gte: startDate, + lt: endOfDay(endDate) + }, symbols, currencies, userCurrency: this.currency @@ -376,7 +461,7 @@ export class PortfolioCalculator { ) { return ( i + 1 < timelineSpecification.length && - !isBefore(currentDate, dparse(timelineSpecification[i + 1].start)) + !isBefore(currentDate, parseDate(timelineSpecification[i + 1].start)) ); } } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 127cc60e7..60d169f2a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -52,6 +52,7 @@ import { HistoricalDataItem, PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; +import { parseDate } from '@ghostfolio/common/helper'; @Injectable() export class PortfolioService { @@ -416,9 +417,9 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); - // TODO: get positions for date range - console.log('Date range:', aDateRange); - const positions = await portfolioCalculator.getCurrentPositions(); + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + const positions = await portfolioCalculator.getCurrentPositions(startDate); return Object.values(positions).map((position) => { return { diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 0da2865ab..d99b039c6 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -1,5 +1,5 @@ import { Currency } from '@prisma/client'; -import { getDate, getMonth, getYear, subDays } from 'date-fns'; +import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; import { ghostfolioScraperApiSymbolPrefix } from './config'; @@ -137,3 +137,9 @@ export function resolveFearAndGreedIndex(aValue: number) { return { emoji: '🤪', text: 'Extreme Greed' }; } } + +export const DATE_FORMAT = 'yyyy-MM-dd'; + +export function parseDate(date: string) { + return parse(date, DATE_FORMAT, new Date()); +}