Files
fuel-alert/app/Services/Forecasting/WeeklyForecastService.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

308 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Forecasting;
use App\Models\Backtest;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\Features\DeltaUlsdLag;
use App\Services\Forecasting\Features\DeltaUlspLag;
use App\Services\Forecasting\Features\IsPreBankHoliday;
use App\Services\Forecasting\Features\UlspMinusMa8;
use App\Services\Forecasting\Features\WeekOfYearTrig;
use App\Services\Forecasting\Models\RidgeRegressionModel;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Layer 1 — orchestrates the ridge model end-to-end:
*
* 1. Builds the canonical v1 feature spec (8 features).
* 2. Trains the ridge model on every available BEIS Monday.
* 3. Predicts for the upcoming Monday.
* 4. Looks up the latest matching backtest for calibrated confidence.
* 5. Returns a flat array keyed for the existing public JSON contract.
*
* Trained-model state is cached for 1 hour (key includes model_version)
* so repeated request hits don't retrain. A new BEIS week or a feature
* spec change rolls model_version, busting the cache automatically.
*/
final class WeeklyForecastService
{
private const float DEFAULT_LAMBDA = 1.0;
public function currentForecast(): array
{
$loader = new WeeklyPumpPriceLoader;
$features = $this->buildFeatures($loader);
$spec = new FeatureSpec('ridge-v1', $features);
$cacheKey = 'forecast:current:'.$spec->modelVersion();
return Cache::remember($cacheKey, 3600, function () use ($loader, $spec, $features): array {
$model = new RidgeRegressionModel($spec, $loader, self::DEFAULT_LAMBDA);
try {
$model->train($this->collectTrainingMondays($loader));
} catch (RuntimeException) {
return $this->insufficientDataPayload($spec);
}
$targetMonday = $this->upcomingMonday();
$prediction = $model->predict($targetMonday);
$rawConfidence = $this->confidenceFromCalibration($spec, $prediction);
$flaggedDutyChange = (new DutyChangeDetector)->isAdjacent($targetMonday);
$confidence = $flaggedDutyChange ? (int) round($rawConfidence / 2) : $rawConfidence;
$directionPublic = $this->mapDirection($prediction->direction);
$action = $this->mapAction($directionPublic, $confidence);
$trailingHitRate = (new AccuracyHistory)->trailingHitRate($spec->modelVersion());
$reasoning = (new ReasoningGenerator)->generate(
$model,
$prediction,
$features,
$targetMonday,
$confidence,
$flaggedDutyChange,
$trailingHitRate,
);
$this->persistForecast($spec, $targetMonday, $prediction, $confidence, $flaggedDutyChange, $reasoning);
return [
'fuel_type' => 'e10',
'current_avg' => $this->nationalCurrentAverage(),
'predicted_direction' => $directionPublic,
'predicted_change_pence' => round($prediction->magnitudePence / 100, 1),
'confidence_score' => $confidence,
'confidence_label' => $this->confidenceLabel($confidence),
'action' => $action,
'reasoning' => $reasoning,
'prediction_horizon_days' => 7,
'region_key' => 'national',
'methodology' => 'ridge_regression_v1',
'model_version' => $spec->modelVersion(),
'flagged_duty_change' => $flaggedDutyChange,
'trailing_hit_rate' => $trailingHitRate,
'weekly_summary' => $this->weeklySummary($loader),
'signals' => $this->describeSignals($model, $prediction),
];
});
}
/**
* Build the canonical v1 feature list. Centralised here so
* WeeklyForecastService and any retraining command share the same
* spec.
*
* @return array<int, ForecastFeature>
*/
private function buildFeatures(WeeklyPumpPriceLoader $loader): array
{
return [
new DeltaUlspLag($loader, lag: 0),
new DeltaUlspLag($loader, lag: 1),
new DeltaUlspLag($loader, lag: 3),
new DeltaUlsdLag($loader, lag: 0),
new UlspMinusMa8($loader),
new WeekOfYearTrig('sin'),
new WeekOfYearTrig('cos'),
new IsPreBankHoliday,
];
}
/** @return array<int, CarbonInterface> */
private function collectTrainingMondays(WeeklyPumpPriceLoader $loader): array
{
return array_map(fn (string $d): CarbonInterface => Carbon::parse($d), $loader->allDates());
}
private function upcomingMonday(): CarbonInterface
{
$today = now()->startOfDay();
return $today->isMonday() ? $today : $today->copy()->next(Carbon::MONDAY);
}
private function confidenceFromCalibration(FeatureSpec $spec, WeeklyPrediction $prediction): int
{
$latest = Backtest::query()
->where('model_version', $spec->modelVersion())
->orderByDesc('ran_at')
->first();
if ($latest === null) {
return 0; // no backtest yet → low (gate 2 will force no_signal)
}
$table = (array) ($latest->calibration_table ?? []);
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
$hitRate = $table[$bin] ?? null;
if ($hitRate === null) {
return (int) round((float) ($latest->directional_accuracy ?? 0));
}
return (int) round(((float) $hitRate) * 100);
}
private function bucketForMagnitude(float $magnitudePence): string
{
$abs = abs($magnitudePence);
return match (true) {
$abs < 50.0 => '0.0-0.5p',
$abs < 100.0 => '0.5-1.0p',
default => '1.0p+',
};
}
private function mapDirection(string $modelDirection): string
{
return match ($modelDirection) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
}
private function mapAction(string $publicDirection, int $confidence): string
{
if ($publicDirection === 'stable' || $confidence < 40) {
return 'no_signal';
}
return $publicDirection === 'up' ? 'fill_now' : 'wait';
}
private function confidenceLabel(int $confidence): string
{
return match (true) {
$confidence >= 70 => 'high',
$confidence >= 40 => 'medium',
default => 'low',
};
}
/**
* Graceful payload when the model can't train (e.g. fresh install,
* not enough BEIS rows yet). Honest about not-knowing — verdict is
* no_signal, confidence 0, reasoning explains why.
*
* @return array<string, mixed>
*/
private function insufficientDataPayload(FeatureSpec $spec): array
{
return [
'fuel_type' => 'e10',
'current_avg' => $this->nationalCurrentAverage(),
'predicted_direction' => 'stable',
'predicted_change_pence' => 0.0,
'confidence_score' => 0,
'confidence_label' => 'low',
'action' => 'no_signal',
'reasoning' => 'Not enough historical BEIS data yet to train the forecast model — staying silent until the series fills in.',
'prediction_horizon_days' => 7,
'region_key' => 'national',
'methodology' => 'ridge_regression_v1',
'model_version' => $spec->modelVersion(),
'weekly_summary' => [
'latest_publication_date' => null,
'latest_avg_pence' => null,
'prior_avg_pence' => null,
'latest_change_pence' => null,
],
'signals' => [],
];
}
private function nationalCurrentAverage(): float
{
$avg = DB::table('station_prices_current')
->where('fuel_type', 'e10')
->avg('price_pence');
return $avg === null ? 0.0 : round((float) $avg / 100, 1);
}
/** @return array<string, mixed> */
private function weeklySummary(WeeklyPumpPriceLoader $loader): array
{
$dates = $loader->allDates();
$latest = end($dates) ?: null;
$prior = $latest === null ? null : ($dates[count($dates) - 2] ?? null);
$todayPence = $latest === null ? null : $loader->ulspPence($latest);
$priorPence = $prior === null ? null : $loader->ulspPence($prior);
return [
'latest_publication_date' => $latest,
'latest_avg_pence' => $todayPence === null ? null : round($todayPence / 100, 1),
'prior_avg_pence' => $priorPence === null ? null : round($priorPence / 100, 1),
'latest_change_pence' => $todayPence !== null && $priorPence !== null
? round(($todayPence - $priorPence) / 100, 1)
: null,
];
}
/**
* Backward-compat 'signals' key. Now describes which features carried
* the most weight in this week's prediction (z-score × β contribution).
*
* @return array<string, array<string, mixed>>
*/
private function describeSignals(RidgeRegressionModel $model, WeeklyPrediction $prediction): array
{
$coeffs = $model->coefficients();
if ($coeffs === null) {
return [];
}
return [
'ridge_v1' => [
'enabled' => true,
'direction' => $prediction->direction,
'magnitude_pence' => round($prediction->magnitudePence / 100, 2),
'feature_count' => count($coeffs['features'] ?? []),
'lambda' => $coeffs['lambda'] ?? null,
],
];
}
/**
* Persist the forecast row so Phase 6's outcome resolver can pair
* it with the actual ULSP when the next BEIS week lands.
* Idempotent on (forecast_for, model_version) via UPSERT.
*/
private function persistForecast(
FeatureSpec $spec,
CarbonInterface $targetMonday,
WeeklyPrediction $prediction,
int $confidence,
bool $flaggedDutyChange,
string $reasoning,
): void {
DB::table('weekly_forecasts')->upsert(
[[
'forecast_for' => $targetMonday->toDateString(),
'model_version' => $spec->modelVersion(),
'direction' => $prediction->direction,
'magnitude_pence' => (int) round($prediction->magnitudePence),
'ridge_confidence' => max(0, min(100, $confidence)),
'flagged_duty_change' => $flaggedDutyChange,
'reasoning' => $reasoning,
'generated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]],
['forecast_for', 'model_version'],
['direction', 'magnitude_pence', 'ridge_confidence', 'flagged_duty_change', 'reasoning', 'generated_at', 'updated_at'],
);
}
}