refactor: split oil price ingestion and prediction into separate services + commands
- BrentPriceFetcher owns ingestion (fetchFromEia / fetchFromFred, each throws on failure) - BrentPricePredictor owns prediction and marks latest brent_prices row as generated - oil:fetch command tries EIA, falls back to FRED, fails loudly if both fail - oil:predict command prompts if latest price already has a prediction; --force bypasses - add prediction_generated_at column to brent_prices - delete OilPriceService (replaced by the two focused services)
This commit is contained in:
135
app/Services/BrentPricePredictor.php
Normal file
135
app/Services/BrentPricePredictor.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?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\Log;
|
||||
|
||||
final class BrentPricePredictor
|
||||
{
|
||||
private const float EWMA_ALPHA = 0.3;
|
||||
|
||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||||
|
||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
||||
|
||||
private const int EWMA_MIN_ROWS = 14;
|
||||
|
||||
public function __construct(
|
||||
private readonly OilPredictionProvider $provider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return the latest BrentPrice row, or null if none exists.
|
||||
*/
|
||||
public function latestPrice(): ?BrentPrice
|
||||
{
|
||||
return BrentPrice::orderBy('date', 'desc')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate EWMA + LLM predictions, store them, and flag the latest
|
||||
* brent_prices row as having a prediction generated.
|
||||
*/
|
||||
public function generatePrediction(): ?PricePrediction
|
||||
{
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
|
||||
if ($prices->count() < self::EWMA_MIN_ROWS) {
|
||||
Log::warning('BrentPricePredictor: not enough price data', [
|
||||
'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());
|
||||
}
|
||||
|
||||
$result = $llm ?? $ewma;
|
||||
|
||||
if ($result !== null) {
|
||||
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float[] $prices Chronological (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);
|
||||
}
|
||||
|
||||
private function ewmaConfidence(float $changePct): int
|
||||
{
|
||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||
|
||||
return (int) round(max(30, $scaled));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user