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)); + }) + ] } } })