Audit items #7 and #5. #7 — BrentPricePredictor::generatePrediction previously wrote both an EWMA row and an LLM row to price_predictions on every run. The downstream OilSignal already prefers llm_with_context > llm > ewma, so the EWMA row was dead weight 95% of the time. Now we try LLM first; if it returns null (no API key, parse failure, etc.) we compute and persist EWMA as a real fallback. This also avoids redundant work on the success path. Updated the "stores both" test to "stores only LLM" — asserts no EWMA row is written when the provider succeeds. #5 — BrentPricePredictor and AnthropicPredictionProvider both had byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3 constants. Extracted to App\Services\Ewma::compute() and dropped both private methods + their alpha constants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
3.6 KiB
PHP
120 lines
3.6 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\LlmPrediction\OilPredictionProvider;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
final class BrentPricePredictor
|
|
{
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Try LLM first; persist EWMA only as a fallback when the LLM provider
|
|
* returns null. The downstream OilSignal already prefers LLM
|
|
* (llm_with_context > llm > ewma), so writing both rows on every run is
|
|
* dead weight 95% of the time. EWMA still acts as the safety net.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
$llm = $this->provider->predict($prices);
|
|
|
|
if ($llm !== null) {
|
|
PricePrediction::create($llm->toArray());
|
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
|
|
|
return $llm;
|
|
}
|
|
|
|
$ewma = $this->generateEwmaPrediction($prices);
|
|
|
|
if ($ewma !== null) {
|
|
PricePrediction::create($ewma->toArray());
|
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
|
}
|
|
|
|
return $ewma;
|
|
}
|
|
|
|
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 = Ewma::compute(array_slice($chronological, -3));
|
|
$ewma7 = Ewma::compute(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(),
|
|
]);
|
|
}
|
|
|
|
private function ewmaConfidence(float $changePct): int
|
|
{
|
|
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
|
|
|
return (int) round(max(30, $scaled));
|
|
}
|
|
}
|