From a7ee9f45571245da44cd996eb3112bba55dc7f9e Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Tue, 14 Apr 2026 16:23:06 +0100 Subject: [PATCH] feat: use EIA as primary Brent crude source with FRED fallback --- app/Services/OilPriceService.php | 89 +++++++++++++++++++-- tests/Unit/Services/OilPriceServiceTest.php | 70 +++++++++++++--- 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/app/Services/OilPriceService.php b/app/Services/OilPriceService.php index 6a74d21..151f8df 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/OilPriceService.php @@ -40,9 +40,84 @@ class OilPriceService ) {} /** - * Fetch the last 30 days of Brent crude prices from FRED and store them. + * Fetch the last 30 days of Brent crude prices. + * Tries EIA first; falls back to FRED if EIA is unavailable. */ public function fetchBrentPrices(): void + { + $rows = $this->fetchFromEia(); + + if ($rows === null) { + Log::warning('OilPriceService: EIA fetch failed, falling back to FRED'); + $rows = $this->fetchFromFred(); + } + + if ($rows === null) { + Log::error('OilPriceService: both EIA and FRED fetch failed'); + + return; + } + + BrentPrice::upsert($rows, ['date'], ['price_usd']); + } + + /** + * Fetch Brent crude prices from the EIA Open Data API. + * Returns mapped rows or null on any failure. + * + * @return array{date: string, price_usd: float}[]|null + */ + private function fetchFromEia(): ?array + { + $url = 'https://api.eia.gov/v2/petroleum/pri/spt/data/'; + + try { + $response = $this->apiLogger->send('eia', 'GET', $url, fn () => Http::timeout(10) + ->get($url, [ + 'api_key' => config('services.eia.api_key'), + 'frequency' => 'daily', + 'data[0]' => 'value', + 'facets[series][]' => 'RBRTE', + 'sort[0][column]' => 'period', + 'sort[0][direction]' => 'desc', + 'length' => 30, + ])); + + if (! $response->successful()) { + Log::error('OilPriceService: EIA request failed', ['status' => $response->status()]); + + return null; + } + + $rows = collect($response->json('response.data') ?? []) + ->filter(fn (array $row) => ($row['value'] ?? '.') !== '.') + ->map(fn (array $row) => [ + 'date' => $row['period'], + 'price_usd' => (float) $row['value'], + ]) + ->all(); + + if (empty($rows)) { + Log::warning('OilPriceService: no valid EIA observations returned'); + + return null; + } + + return $rows; + } catch (Throwable $e) { + Log::error('OilPriceService: fetchFromEia failed', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Fetch Brent crude prices from FRED (fallback). + * Returns mapped rows or null on any failure. + * + * @return array{date: string, price_usd: float}[]|null + */ + private function fetchFromFred(): ?array { $url = 'https://api.stlouisfed.org/fred/series/observations'; @@ -59,11 +134,11 @@ class OilPriceService if (! $response->successful()) { Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]); - return; + return null; } $rows = collect($response->json('observations') ?? []) - ->filter(fn (array $obs) => $obs['value'] !== '.') // FRED uses '.' for missing data + ->filter(fn (array $obs) => $obs['value'] !== '.') ->map(fn (array $obs) => [ 'date' => $obs['date'], 'price_usd' => (float) $obs['value'], @@ -73,12 +148,14 @@ class OilPriceService if (empty($rows)) { Log::warning('OilPriceService: no valid FRED observations returned'); - return; + return null; } - BrentPrice::upsert($rows, ['date'], ['price_usd']); + return $rows; } catch (Throwable $e) { - Log::error('OilPriceService: fetchBrentPrices failed', ['error' => $e->getMessage()]); + Log::error('OilPriceService: fetchFromFred failed', ['error' => $e->getMessage()]); + + return null; } } diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php index 861f3da..5cbe49a 100644 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ b/tests/Unit/Services/OilPriceServiceTest.php @@ -20,11 +20,32 @@ beforeEach(function (): void { // --- fetchBrentPrices --- -it('fetches and stores brent prices from FRED', function (): void { +it('fetches and stores brent prices from EIA when EIA succeeds', function (): void { Http::fake([ + '*eia.gov/*' => Http::response([ + 'response' => [ + 'data' => [ + ['period' => '2026-04-02', 'value' => '73.80'], + ['period' => '2026-04-01', 'value' => '75.10'], + ['period' => '2026-03-31', 'value' => '74.50'], + ], + ], + ]), + '*/fred/*' => Http::response([], 500), + ]); + + $this->service->fetchBrentPrices(); + + expect(BrentPrice::count())->toBe(3) + ->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80'); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'stlouisfed')); +}); + +it('falls back to FRED when EIA returns a 500', function (): void { + Http::fake([ + '*eia.gov/*' => Http::response([], 500), '*/fred/series/observations*' => Http::response([ 'observations' => [ - ['date' => '2026-03-31', 'value' => '74.50'], ['date' => '2026-04-01', 'value' => '75.10'], ['date' => '2026-04-02', 'value' => '73.80'], ], @@ -33,17 +54,44 @@ it('fetches and stores brent prices from FRED', function (): void { $this->service->fetchBrentPrices(); - expect(BrentPrice::count())->toBe(3) - ->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80'); + expect(BrentPrice::count())->toBe(2); }); -it('filters out FRED missing value markers', function (): void { +it('falls back to FRED when EIA returns empty data', function (): void { Http::fake([ + '*eia.gov/*' => Http::response(['response' => ['data' => []]]), '*/fred/series/observations*' => Http::response([ 'observations' => [ ['date' => '2026-04-01', 'value' => '75.10'], - ['date' => '2026-04-02', 'value' => '.'], - ['date' => '2026-04-03', 'value' => '74.20'], + ], + ]), + ]); + + $this->service->fetchBrentPrices(); + + expect(BrentPrice::count())->toBe(1); +}); + +it('stores no rows and logs error when both EIA and FRED fail', function (): void { + Http::fake([ + '*eia.gov/*' => Http::response([], 500), + '*/fred/series/observations*' => Http::response([], 500), + ]); + + $this->service->fetchBrentPrices(); + + expect(BrentPrice::count())->toBe(0); +}); + +it('filters out EIA missing value markers', function (): void { + Http::fake([ + '*eia.gov/*' => Http::response([ + 'response' => [ + 'data' => [ + ['period' => '2026-04-01', 'value' => '75.10'], + ['period' => '2026-04-02', 'value' => '.'], + ['period' => '2026-04-03', 'value' => '74.20'], + ], ], ]), ]); @@ -54,11 +102,11 @@ it('filters out FRED missing value markers', function (): void { ->and(BrentPrice::find('2026-04-02'))->toBeNull(); }); -it('upserts existing brent price rows on refetch', function (): void { +it('upserts existing brent price rows on refetch via EIA', function (): void { Http::fake([ - '*/fred/series/observations*' => Http::sequence() - ->push(['observations' => [['date' => '2026-04-01', 'value' => '74.00']]]) - ->push(['observations' => [['date' => '2026-04-01', 'value' => '75.50']]]), + '*eia.gov/*' => Http::sequence() + ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '74.00']]]]) + ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '75.50']]]]), ]); $this->service->fetchBrentPrices();