From 73542572c94b2c2a0fcef9eb0a583612380c120b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:28:52 +0200 Subject: [PATCH] Support benchmarks whose market data starts later than portfolio --- .../benchmarks/benchmarks.service.spec.ts | 66 ++++++++++++++++--- .../benchmarks/benchmarks.service.ts | 35 ++++++---- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts index 3c1797ebc1..e2a80600d5 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -11,9 +11,10 @@ import { BenchmarksService } from './benchmarks.service'; describe('BenchmarksService', () => { let benchmarksService: BenchmarksService; + let get: jest.Mock; let getExchangeRatesByCurrency: jest.Mock; let getPerformance: jest.Mock; - let get: jest.Mock; + let getRangeCount: jest.Mock; let marketDataItems: jest.Mock; beforeEach(() => { @@ -27,23 +28,26 @@ describe('BenchmarksService', () => { { date: '2010-07-19' } ] }); + getRangeCount = jest.fn().mockResolvedValue(0); marketDataItems = jest.fn(); benchmarksService = new BenchmarksService( new BenchmarkService(null, null, null, null, null, null), { getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, - { marketDataItems } as unknown as MarketDataService, + { getRangeCount, marketDataItems } as unknown as MarketDataService, { getPerformance } as unknown as PortfolioService, { get } as unknown as SymbolService ); }); describe('getMarketDataForUser', () => { - const getMarketDataForUser = () => { + const getMarketDataForUser = ({ + endDate = parseDate('2010-07-19') + }: { endDate?: Date } = {}) => { return benchmarksService.getMarketDataForUser({ + endDate, dataSource: DataSource.COINGECKO, dateRange: 'max', - endDate: parseDate('2010-07-19'), startDate: parseDate('2009-01-02'), symbol: 'bitcoin', userId: 'user-id', @@ -67,9 +71,11 @@ describe('BenchmarksService', () => { { date: '2010-07-19', value: 100 } ] }); + + expect(getRangeCount).not.toHaveBeenCalled(); }); - it('returns 0% before the market data of the benchmark starts', async () => { + 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 } @@ -77,12 +83,17 @@ describe('BenchmarksService', () => { 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 } ] }); + + expect(getRangeCount).toHaveBeenCalledWith({ + assetProfileIdentifiers: [ + { dataSource: DataSource.COINGECKO, symbol: 'bitcoin' } + ], + dateQuery: { lt: new Date('2009-01-01') } + }); }); it('uses the exchange rate at the start of the market data', async () => { @@ -102,14 +113,51 @@ describe('BenchmarksService', () => { 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('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 () => { + getRangeCount.mockResolvedValue(1); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [] + }); + }); + it('returns no market data if the benchmark has none', async () => { marketDataItems.mockResolvedValue([]); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index a46fadb554..74d1e96c8f 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, isBefore, isSameDay, min } from 'date-fns'; +import { format, isSameDay, min } from 'date-fns'; import { isNumber } from 'lodash-es'; @Injectable() @@ -95,8 +95,8 @@ 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%. + // 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; @@ -111,6 +111,27 @@ 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 marketDataCountBeforeBaselineDate = + await this.marketDataService.getRangeCount({ + assetProfileIdentifiers: [{ dataSource, symbol }], + dateQuery: { lt: baselineDate } + }); + + if (marketDataCountBeforeBaselineDate > 0) { + this.logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + baselineDate, + DATE_FORMAT + )}` + ); + + return { marketData }; + } + } + const exchangeRates = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: [currentSymbolItem.currency], @@ -123,14 +144,6 @@ export class BenchmarksService { format(firstMarketDataItem.date, DATE_FORMAT) ]; - for (const { date } of chart) { - if (!isBefore(resetHours(parseDate(date)), firstMarketDataItem.date)) { - break; - } - - marketData.push({ date, value: 0 }); - } - for (const marketDataItem of marketDataItems) { const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[