refactor: extract Brent price sources into dedicated classes

OilPriceService no longer inlines per-provider fetch/transform/error logic.
EIA and FRED are now their own classes with a common shape; the service
just iterates and upserts the first successful result.
This commit is contained in:
Ovidiu U
2026-04-14 16:29:52 +01:00
parent a7ee9f4557
commit 1a0381265e
4 changed files with 139 additions and 114 deletions

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EiaBrentPriceSource
{
private const string URL = 'https://api.eia.gov/v2/petroleum/pri/spt/data/';
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
->get(self::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('EiaBrentPriceSource: 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 ($rows === []) {
Log::warning('EiaBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class FredBrentPriceSource
{
private const string URL = 'https://api.stlouisfed.org/fred/series/observations';
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'sort_order' => 'desc',
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('FredBrentPriceSource: 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 ($rows === []) {
Log::warning('FredBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
}
}
}

View File

@@ -6,11 +6,11 @@ use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\BrentPriceSources\EiaBrentPriceSource;
use App\Services\BrentPriceSources\FredBrentPriceSource;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class OilPriceService
{
@@ -35,128 +35,28 @@ class OilPriceService
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly ApiLogger $apiLogger,
private readonly OilPredictionProvider $provider,
private readonly EiaBrentPriceSource $eia,
private readonly FredBrentPriceSource $fred,
) {}
/**
* Fetch the last 30 days of Brent crude prices.
* Tries EIA first; falls back to FRED if EIA is unavailable.
* Tries each configured source in order; upserts the first successful result.
*/
public function fetchBrentPrices(): void
{
$rows = $this->fetchFromEia();
foreach ([$this->eia, $this->fred] as $source) {
$rows = $source->fetch();
if ($rows === null) {
Log::warning('OilPriceService: EIA fetch failed, falling back to FRED');
$rows = $this->fetchFromFred();
if ($rows !== null) {
BrentPrice::upsert($rows, ['date'], ['price_usd']);
return;
}
}
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;
}
Log::error('OilPriceService: all Brent price sources failed');
}
/**

View File

@@ -5,6 +5,8 @@ use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use App\Services\BrentPriceSources\EiaBrentPriceSource;
use App\Services\BrentPriceSources\FredBrentPriceSource;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\OilPriceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,7 +17,12 @@ uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
$this->provider = Mockery::mock(OilPredictionProvider::class);
$this->service = new OilPriceService(new ApiLogger, $this->provider);
$apiLogger = new ApiLogger;
$this->service = new OilPriceService(
$this->provider,
new EiaBrentPriceSource($apiLogger),
new FredBrentPriceSource($apiLogger),
);
});
// --- fetchBrentPrices ---