From b596abe00affc017a5b23277aba99f96cfeeffc3 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 | 2 +- .../benchmarks/benchmarks.service.spec.ts | 281 ++++++++++++++++++ .../benchmarks/benchmarks.service.ts | 9 +- 3 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bcc5498..68064f971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.51.0 - 2026-08-14 ### Added 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..1dce2e05c --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -0,0 +1,281 @@ +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 { resetHours } from '@ghostfolio/common/helper'; + +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('should return benchmark data for a calendar year date range', async () => { + const startDate = new Date(2024, 11, 31, 23, 59, 59, 999); // 2024-12-31T23:59:59.999 + const endDate = new Date(2025, 11, 31, 23, 59, 59, 999); // 2025-12-31T23:59:59.999 + + // Portfolio chart starts from 2025-01-01 (exclusive boundary excludes 2024-12-31) + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [ + { date: '2025-01-01', value: 100 }, + { date: '2025-06-30', value: 110 }, + { date: '2025-12-31', value: 120 } + ] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + // Market data includes 2024-12-31 (start date) because our fix adds it to the query + mockMarketDataService.marketDataItems.mockResolvedValue([ + { + date: new Date(Date.UTC(2024, 11, 31)), + marketPrice: 100 + }, + { + date: new Date(Date.UTC(2025, 0, 1)), + marketPrice: 100 + }, + { + date: new Date(Date.UTC(2025, 5, 30)), + marketPrice: 110 + }, + { + date: new Date(Date.UTC(2025, 11, 31)), + marketPrice: 120 + } + ]); + + const result = await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange: '2025', + endDate, + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + // Verify the market data query includes resetHours(startDate) + const marketDataCall = + mockMarketDataService.marketDataItems.mock.calls[0][0]; + const dateInArray = marketDataCall.where.date.in; + + // The start date (2024-12-31) should be included in the query + const startDateNormalized = resetHours(startDate); + expect(dateInArray).toContainEqual(startDateNormalized); + + // Chart dates should also be included + expect(dateInArray.length).toBeGreaterThanOrEqual(4); // startDate + 3 chart dates + + // Should return non-empty market data + expect(result.marketData.length).toBeGreaterThan(0); + expect(result.marketData[0].date).toBe('2024-12-31'); + }); + + it('should return benchmark data for a named date range (ytd)', async () => { + const startDate = new Date(Date.UTC(2025, 11, 31)); // 2025-12-31T00:00:00.000Z (ytd start) + const endDate = new Date(); // now + + // Portfolio chart includes 2025-12-31 (midnight, inclusive boundary) + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [ + { date: '2025-12-31', value: 100 }, + { date: '2026-01-15', value: 105 }, + { date: '2026-08-15', value: 120 } + ] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + mockMarketDataService.marketDataItems.mockResolvedValue([ + { + date: new Date(Date.UTC(2025, 11, 31)), + marketPrice: 100 + }, + { + date: new Date(Date.UTC(2026, 0, 15)), + marketPrice: 105 + }, + { + date: new Date(Date.UTC(2026, 7, 15)), + marketPrice: 120 + } + ]); + + const result = await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange: 'ytd', + endDate, + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + expect(result.marketData.length).toBeGreaterThan(0); + expect(result.marketData[0].date).toBe('2025-12-31'); + }); + + it('should return empty market data when market price at start date is unavailable', async () => { + const startDate = new Date(2024, 11, 31, 23, 59, 59, 999); + + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [{ date: '2025-01-01', value: 100 }] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + // No market data at all + mockMarketDataService.marketDataItems.mockResolvedValue([]); + + const result = await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange: '2025', + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + expect(result.marketData).toEqual([]); + }); + + it('should return empty market data when symbol profile is not found', async () => { + const startDate = new Date(2024, 11, 31, 23, 59, 59, 999); + + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [{ date: '2025-01-01', value: 100 }] + }); + + mockSymbolService.get.mockResolvedValue(null); + + 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' } + }); + + expect(result.marketData).toEqual([]); + }); + + it('should include the start date in the market data query for all date ranges', async () => { + const testCases = [ + { + dateRange: '2025' as const, + startDate: new Date(2024, 11, 31, 23, 59, 59, 999), + expectedQueryDate: new Date(Date.UTC(2024, 11, 31)) + }, + { + dateRange: 'ytd' as const, + startDate: new Date(Date.UTC(2025, 11, 31)), + expectedQueryDate: new Date(Date.UTC(2025, 11, 31)) + }, + { + dateRange: '1y' as const, + startDate: new Date(Date.UTC(2025, 7, 15)), + expectedQueryDate: new Date(Date.UTC(2025, 7, 15)) + } + ]; + + for (const { dateRange, startDate, expectedQueryDate } of testCases) { + jest.clearAllMocks(); + + mockPortfolioService.getPerformance.mockResolvedValue({ + chart: [{ date: '2025-01-01', value: 100 }] + }); + + mockSymbolService.get.mockResolvedValue({ + currency: 'USD', + marketPrice: 150 + }); + + mockMarketDataService.marketDataItems.mockResolvedValue([ + { + date: expectedQueryDate, + marketPrice: 100 + }, + { + date: new Date(Date.UTC(2025, 0, 1)), + marketPrice: 105 + } + ]); + + await benchmarksService.getMarketDataForUser({ + dataSource: 'YAHOO', + dateRange, + filters: [], + startDate, + symbol: 'VOO', + userId: 'user-1', + userSettings: { baseCurrency: 'USD' } + }); + + const marketDataCall = + mockMarketDataService.marketDataItems.mock.calls[0][0]; + const dateInArray = marketDataCall.where.date.in; + + expect(dateInArray).toContainEqual(resetHours(startDate)); + } + }); + }); +}); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 4b6a3f1c9..0a330c4dd 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -72,9 +72,12 @@ export class BenchmarksService { dataSource, symbol, date: { - in: chart.map(({ date }) => { - return resetHours(parseDate(date)); - }) + in: [ + resetHours(startDate), + ...chart.map(({ date }) => { + return resetHours(parseDate(date)); + }) + ] } } })