From 1a0381265e2c7d65d6d1a7495053098b98143aa2 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Tue, 14 Apr 2026 16:29:52 +0100 Subject: [PATCH] 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. --- .../BrentPriceSources/EiaBrentPriceSource.php | 60 +++++++++ .../FredBrentPriceSource.php | 58 ++++++++ app/Services/OilPriceService.php | 126 ++---------------- tests/Unit/Services/OilPriceServiceTest.php | 9 +- 4 files changed, 139 insertions(+), 114 deletions(-) create mode 100644 app/Services/BrentPriceSources/EiaBrentPriceSource.php create mode 100644 app/Services/BrentPriceSources/FredBrentPriceSource.php diff --git a/app/Services/BrentPriceSources/EiaBrentPriceSource.php b/app/Services/BrentPriceSources/EiaBrentPriceSource.php new file mode 100644 index 0000000..c89e5d8 --- /dev/null +++ b/app/Services/BrentPriceSources/EiaBrentPriceSource.php @@ -0,0 +1,60 @@ +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; + } + } +} diff --git a/app/Services/BrentPriceSources/FredBrentPriceSource.php b/app/Services/BrentPriceSources/FredBrentPriceSource.php new file mode 100644 index 0000000..8263d4e --- /dev/null +++ b/app/Services/BrentPriceSources/FredBrentPriceSource.php @@ -0,0 +1,58 @@ +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; + } + } +} diff --git a/app/Services/OilPriceService.php b/app/Services/OilPriceService.php index 151f8df..446ef97 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/OilPriceService.php @@ -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'); } /** diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php index 5cbe49a..f5a88e4 100644 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ b/tests/Unit/Services/OilPriceServiceTest.php @@ -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 ---