# EIA Brent Price Source — Primary with FRED Fallback Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace FRED as the primary Brent crude data source with EIA's API, keeping FRED as a silent fallback. **Architecture:** `fetchBrentPrices()` is split into two private methods — `fetchFromEia()` and `fetchFromFred()` — each returning a mapped `array|null`. The public method tries EIA first; on `null`, warns and tries FRED; on second `null`, logs an error and returns. The upsert call is shared and runs once. **Tech Stack:** Laravel `Http` facade, `BrentPrice::upsert()`, `config/services.php`, Pest + `Http::fake()` --- ## Files | Action | File | |--------|------| | Modify | `config/services.php` | | Modify | `.env.example` | | Modify | `app/Services/OilPriceService.php` | | Modify | `tests/Unit/Services/OilPriceServiceTest.php` | --- ## Task 1: Add EIA config key **Files:** - Modify: `config/services.php` - Modify: `.env.example` - [ ] **Step 1: Add EIA service config** In `config/services.php`, add after the `'fred'` block: ```php 'eia' => [ 'api_key' => env('EIA_API_KEY'), ], ``` - [ ] **Step 2: Add EIA key to `.env.example`** Add after the existing `FRED_API_KEY=` line: ``` EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata ``` - [ ] **Step 3: Add your real EIA key to `.env`** Add after `FRED_API_KEY=`: ``` EIA_API_KEY=your_key_here ``` - [ ] **Step 4: Commit** ```bash git add config/services.php .env.example git commit -m "config: add EIA API key for Brent crude price source" ``` --- ## Task 2: Write failing tests for EIA fetch behaviour **Files:** - Modify: `tests/Unit/Services/OilPriceServiceTest.php` - [ ] **Step 1: Replace the three existing FRED fetch tests with four new tests** Replace the `// --- fetchBrentPrices ---` section (lines 22–69) with: ```php // --- fetchBrentPrices --- 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::assertNothingSentTo('*fred*'); }); 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-04-01', 'value' => '75.10'], ['date' => '2026-04-02', 'value' => '73.80'], ], ]), ]); $this->service->fetchBrentPrices(); expect(BrentPrice::count())->toBe(2); }); 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'], ], ]), ]); $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'], ], ], ]), ]); $this->service->fetchBrentPrices(); expect(BrentPrice::count())->toBe(2) ->and(BrentPrice::find('2026-04-02'))->toBeNull(); }); it('upserts existing brent price rows on refetch via EIA', function (): void { Http::fake([ '*/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(); $this->service->fetchBrentPrices(); expect(BrentPrice::count())->toBe(1) ->and(BrentPrice::find('2026-04-01')->price_usd)->toBe('75.50'); }); ``` - [ ] **Step 2: Run the new tests to confirm they fail** ```bash php artisan test --compact tests/Unit/Services/OilPriceServiceTest.php --timeout=10 ``` Expected: several FAIL — "no matching fake" or assertion errors (EIA endpoint not yet implemented). --- ## Task 3: Refactor `OilPriceService::fetchBrentPrices()` **Files:** - Modify: `app/Services/OilPriceService.php` - [ ] **Step 1: Replace `fetchBrentPrices()` and extract private methods** Replace the entire `fetchBrentPrices()` method with: ```php /** * 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'; try { $response = $this->apiLogger->send('fred', 'GET', $url, fn () => Http::timeout(10) ->get($url, [ 'series_id' => 'DCOILBRENTEU', 'api_key' => config('services.fred.api_key'), 'sort_order' => 'desc', 'limit' => 30, 'file_type' => 'json', ])); if (! $response->successful()) { Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]); return null; } $rows = collect($response->json('observations') ?? []) ->filter(fn (array $obs) => $obs['value'] !== '.') ->map(fn (array $obs) => [ 'date' => $obs['date'], 'price_usd' => (float) $obs['value'], ]) ->all(); if (empty($rows)) { Log::warning('OilPriceService: no valid FRED observations returned'); return null; } return $rows; } catch (Throwable $e) { Log::error('OilPriceService: fetchFromFred failed', ['error' => $e->getMessage()]); return null; } } ``` - [ ] **Step 2: Run Pint to fix formatting** ```bash vendor/bin/pint app/Services/OilPriceService.php --format agent ``` - [ ] **Step 3: Run the tests** ```bash php artisan test --compact tests/Unit/Services/OilPriceServiceTest.php --timeout=10 ``` Expected: all PASS. - [ ] **Step 4: Commit** ```bash git add app/Services/OilPriceService.php tests/Unit/Services/OilPriceServiceTest.php git commit -m "feat: use EIA as primary Brent crude source with FRED fallback" ``` --- ## Task 4: Smoke-test the live fetch - [ ] **Step 1: Run the command against the live APIs** ```bash php artisan oil:predict --fetch ``` Expected output: starts with "Fetching latest Brent crude prices from FRED..." (existing message — acceptable), followed by a successful prediction line. No error output. - [ ] **Step 2: Verify EIA data landed in the database** ```bash php artisan tinker --execute 'echo App\Models\BrentPrice::orderBy("date","desc")->value("date");' ``` Expected: a date more recent than `2026-04-02` if EIA has newer data, otherwise `2026-04-02` (EIA and FRED may both be current to the same date — that is acceptable).