From 4ce5066596a4c8b1c89f5a252cba0a1c0290d881 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 20:04:41 +0100 Subject: [PATCH] refactor: persist EWMA only on LLM failure, dedup EWMA helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Services/BrentPricePredictor.php | 42 ++++++------------- app/Services/Ewma.php | 25 +++++++++++ .../AnthropicPredictionProvider.php | 21 ++-------- .../Unit/Services/BrentPricePredictorTest.php | 5 ++- 4 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 app/Services/Ewma.php 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 {