Files
fuel-price/docs/superpowers/plans/2026-04-14-eia-brent-price-fallback.md
Ovidiu U aec547cd86
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
refactor: restructure Stripe pricing config to support monthly and annual tiers
- Nest price IDs under `monthly` and `annual` keys for each tier (basic, plus, pro)
2026-04-14 19:26:01 +01:00

355 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 2269) 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).