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.
168 lines
5.0 KiB
PHP
168 lines
5.0 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
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\Log;
|
||
|
||
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 OilPredictionProvider $provider,
|
||
private readonly EiaBrentPriceSource $eia,
|
||
private readonly FredBrentPriceSource $fred,
|
||
) {}
|
||
|
||
/**
|
||
* Fetch the last 30 days of Brent crude prices.
|
||
* Tries each configured source in order; upserts the first successful result.
|
||
*/
|
||
public function fetchBrentPrices(): void
|
||
{
|
||
foreach ([$this->eia, $this->fred] as $source) {
|
||
$rows = $source->fetch();
|
||
|
||
if ($rows !== null) {
|
||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
Log::error('OilPriceService: all Brent price sources failed');
|
||
}
|
||
|
||
/**
|
||
* 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 0–EWMA_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));
|
||
}
|
||
}
|