From 7d5ba433148dfec7a5f3ead61fddd8c813150377 Mon Sep 17 00:00:00 2001 From: aaryamantriescode Date: Sat, 15 Aug 2026 14:57:41 +0530 Subject: [PATCH] fix(benchmarks): include start date in market data query for calendar year ranges For calendar year date ranges (e.g. '2025'), getIntervalFromDateRange() sets startDate to the last millisecond of the previous year (2024-12-31T23:59:59.999) as an exclusive boundary. The portfolio chart excludes this date via isBefore(), so the market data query (built from chart dates) never fetches the baseline price for that date. This causes the benchmark line to be missing from the chart. Add resetHours(startDate) to the date.in array in the market data query so the start date's market data is always fetched, regardless of whether it appears in the portfolio chart. For named ranges where the start date is already in the chart, this is a harmless duplicate. Add unit tests covering calendar year and named date ranges. --- CHANGELOG.md | 1 + .../benchmarks/benchmarks.service.spec.ts | 141 ++++++++++++++++++ .../benchmarks/benchmarks.service.ts | 11 +- 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d0e85f1..3f6b0d311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the missing benchmark in the performance chart for calendar year date ranges on the Analysis page - Fixed the country mapping of Macau in the _Financial Modeling Prep_ service - Fixed the asset profile and historical market data gathering of a symbol getting blocked permanently by a failed job by discarding the failed jobs - Fixed the asset profile data gathering of a symbol in the admin control panel by removing an existing job before enqueueing a new one 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 000000000..91211d641 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -0,0 +1,141 @@ +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 { BenchmarksService } from './benchmarks.service'; + +describe('BenchmarksService', () => { + let benchmarksService: BenchmarksService; + + const mockBenchmarkService = { + calculateChangeInPercentage: jest.fn( + (startPrice: number, endPrice: number) => { + return (endPrice - startPrice) / startPrice; + } + ) + }; + + const mockExchangeRateDataService = { + getExchangeRatesByCurrency: jest.fn().mockResolvedValue({}) + }; + + const mockMarketDataService = { + marketDataItems: jest.fn() + }; + + const mockPortfolioService = { + getPerformance: jest.fn() + }; + + const mockSymbolService = { + get: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + + benchmarksService = new BenchmarksService( + mockBenchmarkService as unknown as BenchmarkService, + mockExchangeRateDataService as unknown as ExchangeRateDataService, + mockMarketDataService as unknown as MarketDataService, + mockPortfolioService as unknown as PortfolioService, + mockSymbolService as unknown as SymbolService + ); + }); + + describe('getMarketDataForUser', () => { + it('returns benchmark data for a calendar year with the first chart date as baseline', async () => { + const startDate = new Date(2024, 11, 31, 23, 59, 59, 999); + const endDate = new Date(2025, 11, 31, 23, 59, 59, 999); + + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [ + { date: '2025-01-01', value: 0 }, + { date: '2025-06-30', value: 10 }, + { date: '2025-12-31', value: 20 } + ] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + mockMarketDataService.marketDataItems.mockResolvedValue([ + { date: new Date(Date.UTC(2025, 0, 1)), marketPrice: 100 }, + { date: new Date(Date.UTC(2025, 5, 30)), marketPrice: 120 }, + { date: new Date(Date.UTC(2025, 11, 31)), marketPrice: 150 } + ]); + + const result = await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange: '2025', + endDate, + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + expect(result.marketData.length).toBeGreaterThan(0); + + const baselineItem = result.marketData.find( + ({ date }) => date === '2025-01-01' + ); + expect(baselineItem).toBeDefined(); + expect(baselineItem.value).toBe(0); + + const junItem = result.marketData.find( + ({ date }) => date === '2025-06-30' + ); + expect(junItem).toEqual({ + date: '2025-06-30', + value: expect.closeTo(((120 - 100) / 100) * 100, 10) + }); + + const decItem = result.marketData.find( + ({ date }) => date === '2025-12-31' + ); + expect(decItem).toEqual({ + date: '2025-12-31', + value: expect.closeTo(((150 - 100) / 100) * 100, 10) + }); + }); + + it('uses resetHours(startDate) as the baseline when the chart is empty', async () => { + const startDate = new Date(2024, 11, 31, 23, 59, 59, 999); + + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + mockMarketDataService.marketDataItems.mockResolvedValue([ + { date: new Date(Date.UTC(2024, 11, 31)), marketPrice: 100 } + ]); + + const result = await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange: '2025', + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + const baselineItem = result.marketData.find( + ({ date }) => date === '2024-12-31' + ); + expect(baselineItem).toBeDefined(); + expect(baselineItem.value).toBe(0); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 4b6a3f1c9..2833dfe6f 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -88,6 +88,11 @@ export class BenchmarksService { return { marketData }; } + const baselineDate = + chart.length > 0 + ? resetHours(parseDate(chart[0].date)) + : resetHours(startDate); + const exchangeRates = await this.exchangeRateDataService.getExchangeRatesByCurrency({ startDate, @@ -97,17 +102,17 @@ export class BenchmarksService { const exchangeRateAtStartDate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(startDate, DATE_FORMAT) + format(baselineDate, DATE_FORMAT) ]; const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { - return isSameDay(date, startDate); + return isSameDay(date, baselineDate); })?.marketPrice; if (!marketPriceAtStartDate) { this.logger.error( `No historical market data has been found for ${symbol} (${dataSource}) at ${format( - startDate, + baselineDate, DATE_FORMAT )}` );