Browse Source

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.
pull/7631/head
aaryamantriescode 3 days ago
parent
commit
b596abe00a
  1. 2
      CHANGELOG.md
  2. 281
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts
  3. 5
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

2
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

281
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));
}
});
});
});

5
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -72,9 +72,12 @@ export class BenchmarksService {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
in: [
resetHours(startDate),
...chart.map(({ date }) => {
return resetHours(parseDate(date));
})
]
}
}
})

Loading…
Cancel
Save