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>
88 lines
2.7 KiB
PHP
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',
|
|
};
|
|
}
|
|
}
|