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 */ 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 */ 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 */ 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 */ 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> */ 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'], ); } }