Files
fuel-alert/app/Services/Forecasting/OutcomeResolver.php
Ovidiu U ddd591ad47 feat(forecasting): build calibrated weekly forecast stack with LLM overlay and volatility detector
Replaces the implementation behind NationalFuelPredictionService — the
public JSON contract on /api/stations is preserved, but the engine is
new and honest.

Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md):
1. Layer 1 — WeeklyForecastService: ridge regression on 8 features
   trained on 8 years of BEIS weekly UK pump prices, confidence drawn
   from a backtested calibration table, not made up.
2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over
   station_prices_current. Never speaks about the future.
3. Layer 3 — verdict via rule gates, not confidence multipliers. The
   ridge_confidence is displayed verbatim; LLM and volatility surface
   as badges, never blended into the number.
4. Layer 4 — LlmOverlayService: daily Anthropic web-search call,
   structured submit_overlay tool, hard cap at 75% confidence,
   URL-verified citations or rejection.
5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the
   active flag, OR-combined triggers (Brent move >3%, LLM major
   impact, station churn (gated), watched_events).

Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the
8x8 normal-equation matrix. No external ML dependency. Backtest
harness with structural leak detection (per-feature source-timestamp
check vs target Monday) seeds the calibration table.

Backtest gate (62–68% directional accuracy on the 130-week hold-out)
ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change
baseline by ~30pp on real data.

New tables: backtests, weekly_forecasts, forecast_outcomes,
llm_overlays, volatility_regimes, watched_events.

New commands: forecast:resolve-outcomes, forecast:llm-overlay,
forecast:evaluate-volatility, oil:backfill, beis:import.

Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK,
forecast:evaluate-volatility hourly, beis:import Mon 09:30,
forecast:resolve-outcomes Mon 10:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:05 +01:00

88 lines
2.7 KiB
PHP

<?php
namespace App\Services\Forecasting;
use App\Models\WeeklyForecast;
use Illuminate\Support\Facades\DB;
/**
* Pairs a `weekly_forecasts` row with the actual ULSP move once BEIS
* publishes the matching week. Writes idempotent rows to
* `forecast_outcomes` so trailing-13-week accuracy is honest, not
* inferred.
*/
final class OutcomeResolver
{
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0;
public function resolvePending(): int
{
$resolved = 0;
$existing = DB::table('forecast_outcomes')
->select(['forecast_for', 'model_version'])
->get()
->mapWithKeys(fn ($r): array => [$r->forecast_for.'|'.$r->model_version => true])
->all();
$candidates = WeeklyForecast::query()
->where('forecast_for', '<=', now()->toDateString())
->orderBy('forecast_for')
->get();
foreach ($candidates as $forecast) {
$key = $forecast->forecast_for->toDateString().'|'.$forecast->model_version;
if (isset($existing[$key])) {
continue;
}
$actualDelta = $this->actualDeltaPence($forecast->forecast_for->toDateString());
if ($actualDelta === null) {
continue;
}
$actualClass = $this->classifyDirection($actualDelta);
$absError = (int) round(abs($forecast->magnitude_pence - $actualDelta));
DB::table('forecast_outcomes')->insert([
'forecast_for' => $forecast->forecast_for->toDateString(),
'model_version' => $forecast->model_version,
'predicted_class' => $forecast->direction,
'actual_class' => $actualClass,
'correct' => $forecast->direction === $actualClass,
'abs_error_pence' => $absError,
'resolved_at' => now(),
]);
$resolved++;
}
return $resolved;
}
private function actualDeltaPence(string $targetDate): ?float
{
$current = DB::table('weekly_pump_prices')
->where('date', $targetDate)
->value('ulsp_pence');
$previous = DB::table('weekly_pump_prices')
->where('date', date('Y-m-d', strtotime($targetDate.' -7 days')))
->value('ulsp_pence');
if ($current === null || $previous === null) {
return null;
}
return (float) ($current - $previous);
}
private function classifyDirection(float $deltaPence): string
{
return match (true) {
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
default => 'flat',
};
}
}