diff --git a/app/Services/BrentPricePredictor.php b/app/Services/BrentPricePredictor.php index 95d94ad..b50338d 100644 --- a/app/Services/BrentPricePredictor.php +++ b/app/Services/BrentPricePredictor.php @@ -12,8 +12,6 @@ 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; @@ -33,8 +31,10 @@ final class BrentPricePredictor } /** - * Generate EWMA + LLM predictions, store them, and flag the latest - * brent_prices row as having a prediction generated. + * 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 { @@ -48,25 +48,23 @@ final class BrentPricePredictor 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()); + $prices->first()->forceFill(['prediction_generated_at' => now()])->save(); + + return $llm; } - $result = $llm ?? $ewma; + $ewma = $this->generateEwmaPrediction($prices); - if ($result !== null) { + if ($ewma !== null) { + PricePrediction::create($ewma->toArray()); $prices->first()->forceFill(['prediction_generated_at' => now()])->save(); } - return $result; + return $ewma; } public function generateEwmaPrediction(Collection $prices): ?PricePrediction @@ -77,8 +75,8 @@ final class BrentPricePredictor return null; } - $ewma3 = $this->computeEwma(array_slice($chronological, -3)); - $ewma7 = $this->computeEwma(array_slice($chronological, -7)); + $ewma3 = Ewma::compute(array_slice($chronological, -3)); + $ewma7 = Ewma::compute(array_slice($chronological, -7)); $changePct = (($ewma3 - $ewma7) / $ewma7) * 100; @@ -112,20 +110,6 @@ final class BrentPricePredictor ]); } - /** - * @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; diff --git a/app/Services/Ewma.php b/app/Services/Ewma.php new file mode 100644 index 0000000..1dd226a --- /dev/null +++ b/app/Services/Ewma.php @@ -0,0 +1,25 @@ +sortBy('date'); - $ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all()); - $ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all()); - $ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all()); + $ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all()); + $ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all()); + $ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all()); $url = 'https://api.anthropic.com/v1/messages'; @@ -229,16 +228,4 @@ class AnthropicPredictionProvider extends AbstractLlmPredictionProvider return $block['input'] ?? null; } - - /** @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); - } } diff --git a/tests/Unit/Services/BrentPricePredictorTest.php b/tests/Unit/Services/BrentPricePredictorTest.php index 9025427..6c8a228 100644 --- a/tests/Unit/Services/BrentPricePredictorTest.php +++ b/tests/Unit/Services/BrentPricePredictorTest.php @@ -61,7 +61,7 @@ it('returns null when fewer than 14 prices are available for EWMA', function (): expect($this->predictor->generateEwmaPrediction($prices))->toBeNull(); }); -it('stores both EWMA and LLM predictions when provider succeeds', function (): void { +it('stores only the LLM prediction when the provider succeeds', function (): void { seedPrices(20); $this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([ @@ -76,7 +76,8 @@ it('stores both EWMA and LLM predictions when provider succeeds', function (): v $prediction = $this->predictor->generatePrediction(); expect($prediction->source)->toBe(PredictionSource::LlmWithContext) - ->and(PricePrediction::count())->toBe(2); + ->and(PricePrediction::count())->toBe(1) + ->and(PricePrediction::where('source', PredictionSource::Ewma)->count())->toBe(0); }); it('falls back to EWMA when provider returns null', function (): void {