refactor: persist EWMA only on LLM failure, dedup EWMA helper
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>
This commit is contained in:
@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
final class BrentPricePredictor
|
final class BrentPricePredictor
|
||||||
{
|
{
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||||||
|
|
||||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
private const int EWMA_MAX_CONFIDENCE = 65;
|
||||||
@@ -33,8 +31,10 @@ final class BrentPricePredictor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate EWMA + LLM predictions, store them, and flag the latest
|
* Try LLM first; persist EWMA only as a fallback when the LLM provider
|
||||||
* brent_prices row as having a prediction generated.
|
* 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
|
public function generatePrediction(): ?PricePrediction
|
||||||
{
|
{
|
||||||
@@ -48,25 +48,23 @@ final class BrentPricePredictor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ewma = $this->generateEwmaPrediction($prices);
|
|
||||||
|
|
||||||
if ($ewma !== null) {
|
|
||||||
PricePrediction::create($ewma->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
$llm = $this->provider->predict($prices);
|
$llm = $this->provider->predict($prices);
|
||||||
|
|
||||||
if ($llm !== null) {
|
if ($llm !== null) {
|
||||||
PricePrediction::create($llm->toArray());
|
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();
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $ewma;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
||||||
@@ -77,8 +75,8 @@ final class BrentPricePredictor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
$ewma3 = Ewma::compute(array_slice($chronological, -3));
|
||||||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
$ewma7 = Ewma::compute(array_slice($chronological, -7));
|
||||||
|
|
||||||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
$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
|
private function ewmaConfidence(float $changePct): int
|
||||||
{
|
{
|
||||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||||
|
|||||||
25
app/Services/Ewma.php
Normal file
25
app/Services/Ewma.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponentially-weighted moving average. Pure function — used by
|
||||||
|
* BrentPricePredictor for the EWMA fallback prediction and by
|
||||||
|
* AnthropicPredictionProvider to enrich the basic-flow prompt.
|
||||||
|
*/
|
||||||
|
final class Ewma
|
||||||
|
{
|
||||||
|
public const float DEFAULT_ALPHA = 0.3;
|
||||||
|
|
||||||
|
/** @param float[] $prices Chronological order (oldest first). */
|
||||||
|
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
|
||||||
|
{
|
||||||
|
$ema = $prices[0];
|
||||||
|
|
||||||
|
foreach (array_slice($prices, 1) as $price) {
|
||||||
|
$ema = $alpha * $price + (1 - $alpha) * $ema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($ema, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Services\LlmPrediction;
|
|||||||
|
|
||||||
use App\Enums\PredictionSource;
|
use App\Enums\PredictionSource;
|
||||||
use App\Models\PricePrediction;
|
use App\Models\PricePrediction;
|
||||||
|
use App\Services\Ewma;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -11,8 +12,6 @@ use Throwable;
|
|||||||
|
|
||||||
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||||||
* Overrides the parent flow because Anthropic uses two phases (web search
|
* Overrides the parent flow because Anthropic uses two phases (web search
|
||||||
@@ -112,9 +111,9 @@ class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
|||||||
private function predictBasic(Collection $prices): ?PricePrediction
|
private function predictBasic(Collection $prices): ?PricePrediction
|
||||||
{
|
{
|
||||||
$chronological = $prices->sortBy('date');
|
$chronological = $prices->sortBy('date');
|
||||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
|
||||||
|
|
||||||
$url = 'https://api.anthropic.com/v1/messages';
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
@@ -229,16 +228,4 @@ class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
|||||||
|
|
||||||
return $block['input'] ?? null;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ it('returns null when fewer than 14 prices are available for EWMA', function ():
|
|||||||
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
|
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);
|
seedPrices(20);
|
||||||
|
|
||||||
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
|
$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();
|
$prediction = $this->predictor->generatePrediction();
|
||||||
|
|
||||||
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
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 {
|
it('falls back to EWMA when provider returns null', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user