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)); } }