diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e2175e59..8e0c719a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio - Harmonized the colors in the investment chart component - Improved the language localization for Chinese (`zh`) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts new file mode 100644 index 0000000000..864f1d5a42 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -0,0 +1,178 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { DataSource } from '@prisma/client'; + +import { BenchmarksService } from './benchmarks.service'; + +describe('BenchmarksService', () => { + let benchmarksService: BenchmarksService; + let get: jest.Mock; + let getExchangeRatesByCurrency: jest.Mock; + let getPerformance: jest.Mock; + let getRange: jest.Mock; + let marketDataItems: jest.Mock; + + beforeEach(() => { + get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }); + getExchangeRatesByCurrency = jest.fn().mockResolvedValue({}); + getPerformance = jest.fn().mockResolvedValue({ + chart: [ + { date: '2009-01-01' }, + { date: '2009-06-01' }, + { date: '2010-07-18' }, + { date: '2010-07-19' } + ] + }); + getRange = jest.fn().mockResolvedValue([]); + marketDataItems = jest.fn(); + + benchmarksService = new BenchmarksService( + new BenchmarkService(null, null, null, null, null, null), + { getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, + { getRange, marketDataItems } as unknown as MarketDataService, + { getPerformance } as unknown as PortfolioService, + { get } as unknown as SymbolService + ); + }); + + describe('getMarketDataForUser', () => { + const getMarketDataForUser = ({ + endDate = parseDate('2010-07-19') + }: { endDate?: Date } = {}) => { + return benchmarksService.getMarketDataForUser({ + endDate, + dataSource: DataSource.COINGECKO, + dateRange: 'max', + startDate: parseDate('2009-01-02'), + symbol: 'bitcoin', + userId: 'user-id', + userSettings: { baseCurrency: 'CHF' } + }); + }; + + it('calculates the change since the baseline date', async () => { + marketDataItems.mockResolvedValue([ + { date: new Date('2009-01-01'), marketPrice: 50 }, + { date: new Date('2009-06-01'), marketPrice: 25 }, + { date: new Date('2010-07-18'), marketPrice: 75 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2009-01-01', value: 0 }, + { date: '2009-06-01', value: -50 }, + { date: '2010-07-18', value: 50 }, + { date: '2010-07-19', value: 100 } + ] + }); + + expect(getRange).not.toHaveBeenCalled(); + }); + + it('returns no values before the market data of the benchmark starts', async () => { + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 100 } + ] + }); + + expect(getRange).toHaveBeenCalledWith({ + assetProfileIdentifiers: [ + { dataSource: DataSource.COINGECKO, symbol: 'bitcoin' } + ], + dateQuery: { lt: new Date('2009-01-01') }, + take: 1 + }); + + expect(marketDataItems).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { date: 'asc' } }) + ); + }); + + it('uses the exchange rate at the start of the market data', async () => { + getExchangeRatesByCurrency.mockResolvedValue({ + USDCHF: { + '2009-01-01': 2, + '2009-06-01': 2, + '2010-07-18': 1, + '2010-07-19': 1.5 + } + }); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 200 } + ] + }); + }); + + it('calculates the value at the end date since the start of the market data', async () => { + getExchangeRatesByCurrency.mockResolvedValue({ + USDCHF: { + '2009-01-01': 2, + '2010-07-18': 1, + '2010-07-19': 1, + '2010-07-20': 1.5 + } + }); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect( + getMarketDataForUser({ endDate: parseDate('2010-07-20') }) + ).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 100 }, + { date: '2010-07-20', value: 500 } + ] + }); + }); + + it('returns no market data if the market data at the baseline date is missing', async () => { + getRange.mockResolvedValue([ + { date: new Date('2008-12-31'), marketPrice: 40 } + ]); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [] + }); + + expect(getExchangeRatesByCurrency).not.toHaveBeenCalled(); + }); + + it('returns no market data if the benchmark has none', async () => { + marketDataItems.mockResolvedValue([]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [] + }); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index d807b1bb6b..5104f01f35 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -94,25 +94,15 @@ export class BenchmarksService { const baselineDate = resetHours(parseDate(chart[0].date)); - const exchangeRates = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: [currentSymbolItem.currency], - startDate: min([baselineDate, startDate]), - targetCurrency: userCurrency - }); - - const exchangeRateAtStartDate = - exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(baselineDate, DATE_FORMAT) - ]; - - const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { - return isSameDay(date, baselineDate); - })?.marketPrice; + // The market data of the benchmark can start after the baseline date. + // In this case, the first market data item is the start and there is no + // value before it. + const [firstMarketDataItem] = marketDataItems; + const marketPriceAtStartDate = firstMarketDataItem?.marketPrice; if (!marketPriceAtStartDate) { this.logger.error( - `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + `No historical market data has been found for ${symbol} (${dataSource}) since ${format( baselineDate, DATE_FORMAT )}` @@ -121,6 +111,47 @@ export class BenchmarksService { return { marketData }; } + if (!isSameDay(firstMarketDataItem.date, baselineDate)) { + // If market data exists before the baseline date, the market data at + // the baseline date is missing and the benchmark does not start later + const [marketDataItemBeforeBaselineDate] = + await this.marketDataService.getRange({ + assetProfileIdentifiers: [{ dataSource, symbol }], + dateQuery: { lt: baselineDate }, + take: 1 + }); + + if (marketDataItemBeforeBaselineDate) { + this.logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + baselineDate, + DATE_FORMAT + )}` + ); + + return { marketData }; + } + + this.logger.warn( + `The market data of ${symbol} (${dataSource}) starts at ${format( + firstMarketDataItem.date, + DATE_FORMAT + )}, after the baseline date ${format(baselineDate, DATE_FORMAT)}` + ); + } + + const exchangeRates = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: [currentSymbolItem.currency], + startDate: min([baselineDate, startDate]), + targetCurrency: userCurrency + }); + + const exchangeRateAtStartDate = + exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ + format(firstMarketDataItem.date, DATE_FORMAT) + ]; + for (const marketDataItem of marketDataItems) { const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[