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