From 64161b821a93777b401014bdd1e983e43e4e097f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:24:22 +0200 Subject: [PATCH] Support benchmarks whose market data starts later than portfolio --- .../benchmarks/benchmarks.service.spec.ts | 121 ++++++++++++++++++ .../benchmarks/benchmarks.service.ts | 38 ++++-- 2 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts 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..3c1797ebc1 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -0,0 +1,121 @@ +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 getExchangeRatesByCurrency: jest.Mock; + let getPerformance: jest.Mock; + let get: 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' } + ] + }); + marketDataItems = jest.fn(); + + benchmarksService = new BenchmarksService( + new BenchmarkService(null, null, null, null, null, null), + { getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, + { marketDataItems } as unknown as MarketDataService, + { getPerformance } as unknown as PortfolioService, + { get } as unknown as SymbolService + ); + }); + + describe('getMarketDataForUser', () => { + const getMarketDataForUser = () => { + return benchmarksService.getMarketDataForUser({ + dataSource: DataSource.COINGECKO, + dateRange: 'max', + endDate: parseDate('2010-07-19'), + 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 } + ] + }); + }); + + it('returns 0% 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: '2009-01-01', value: 0 }, + { date: '2009-06-01', value: 0 }, + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 100 } + ] + }); + }); + + 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: '2009-01-01', value: 0 }, + { date: '2009-06-01', value: 0 }, + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 200 } + ] + }); + }); + + 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..a46fadb554 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -13,7 +13,7 @@ import { import { DateRange } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { format, isSameDay, min } from 'date-fns'; +import { format, isBefore, isSameDay, min } from 'date-fns'; import { isNumber } from 'lodash-es'; @Injectable() @@ -94,6 +94,23 @@ export class BenchmarksService { const baselineDate = resetHours(parseDate(chart[0].date)); + // The market data of the benchmark can start after the baseline date. + // In this case, the first market data item is the start and the value + // before it is 0%. + const [firstMarketDataItem] = marketDataItems; + const marketPriceAtStartDate = firstMarketDataItem?.marketPrice; + + if (!marketPriceAtStartDate) { + this.logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) since ${format( + baselineDate, + DATE_FORMAT + )}` + ); + + return { marketData }; + } + const exchangeRates = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: [currentSymbolItem.currency], @@ -103,22 +120,15 @@ export class BenchmarksService { const exchangeRateAtStartDate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(baselineDate, DATE_FORMAT) + format(firstMarketDataItem.date, DATE_FORMAT) ]; - const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { - return isSameDay(date, baselineDate); - })?.marketPrice; + for (const { date } of chart) { + if (!isBefore(resetHours(parseDate(date)), firstMarketDataItem.date)) { + break; + } - if (!marketPriceAtStartDate) { - this.logger.error( - `No historical market data has been found for ${symbol} (${dataSource}) at ${format( - baselineDate, - DATE_FORMAT - )}` - ); - - return { marketData }; + marketData.push({ date, value: 0 }); } for (const marketDataItem of marketDataItems) {