Files
fuel-price/app/Services/OilPriceService.php

268 lines
8.1 KiB
PHP
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.

<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class OilPriceService
{
/**
* Decay factor for EWMA. Higher = more weight on recent prices.
*/
private const float EWMA_ALPHA = 0.3;
/**
* Minimum % change in EWMA to be considered rising/falling.
*/
private const float EWMA_THRESHOLD_PCT = 1.5;
/**
* EWMA confidence is capped lower than LLM — it's a simpler model.
*/
private const int EWMA_MAX_CONFIDENCE = 65;
/**
* Minimum price rows needed before EWMA is meaningful.
*/
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly ApiLogger $apiLogger,
private readonly OilPredictionProvider $provider,
) {}
/**
* 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;
}
}
/**
* Generate predictions from all available sources and store each one.
* EWMA always runs. LLM provider runs and returns null if not configured.
* Returns the highest-confidence prediction (LLM preferred over EWMA).
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('OilPriceService: not enough price data to generate prediction', [
'rows' => $prices->count(),
]);
return null;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
}
return $llm ?? $ewma;
}
/**
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
*/
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
/**
* Compute Exponential Weighted Moving Average for a series of prices.
*
* @param float[] $prices Chronological order (oldest first)
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
/**
* Map a % change magnitude to a 0EWMA_MAX_CONFIDENCE confidence score.
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
*/
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}