diff --git a/app/Console/Commands/BackfillOilPrices.php b/app/Console/Commands/BackfillOilPrices.php new file mode 100644 index 0000000..2f7551a --- /dev/null +++ b/app/Console/Commands/BackfillOilPrices.php @@ -0,0 +1,33 @@ +option('from'); + $to = (string) ($this->option('to') ?: now()->toDateString()); + + $this->info("Backfilling Brent ({$from} → {$to}) from FRED..."); + + try { + $count = $fetcher->backfillFromFred($from, $to); + $this->info(sprintf('Upserted %d Brent rows.', $count)); + + return self::SUCCESS; + } catch (BrentPriceFetchException $e) { + $this->error('FRED backfill failed: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/EvaluateVolatilityRegime.php b/app/Console/Commands/EvaluateVolatilityRegime.php new file mode 100644 index 0000000..50f1c11 --- /dev/null +++ b/app/Console/Commands/EvaluateVolatilityRegime.php @@ -0,0 +1,30 @@ +evaluate(); + + if ($regime === null) { + $this->info('Volatility regime: OFF'); + } else { + $this->info(sprintf( + 'Volatility regime: ON (trigger=%s, since %s)', + $regime->trigger, + $regime->flipped_on_at->toIso8601String(), + )); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportBeisFuelPrices.php b/app/Console/Commands/ImportBeisFuelPrices.php new file mode 100644 index 0000000..a5cc18b --- /dev/null +++ b/app/Console/Commands/ImportBeisFuelPrices.php @@ -0,0 +1,35 @@ +import(); + } catch (Throwable $e) { + $this->error('BEIS import failed: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + 'Imported %d rows from %s — latest date: %s.', + $result['parsed'], + $result['csv_url'], + $result['latest_date'], + )); + $this->info('Forecast cache flushed; next API hit will retrain on the new row.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ResolveForecastOutcomes.php b/app/Console/Commands/ResolveForecastOutcomes.php new file mode 100644 index 0000000..2b6a8fd --- /dev/null +++ b/app/Console/Commands/ResolveForecastOutcomes.php @@ -0,0 +1,21 @@ +resolvePending(); + $this->info(sprintf('Resolved %d outstanding forecast(s).', $count)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RunLlmOverlay.php b/app/Console/Commands/RunLlmOverlay.php new file mode 100644 index 0000000..93c0e78 --- /dev/null +++ b/app/Console/Commands/RunLlmOverlay.php @@ -0,0 +1,34 @@ +run(eventDriven: (bool) $this->option('event-driven')); + + if ($row === null) { + $this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).'); + + return self::SUCCESS; + } + + $this->info(sprintf( + 'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.', + $row->id, + $row->direction, + $row->confidence, + $row->major_impact_event ? 'YES' : 'no', + )); + + return self::SUCCESS; + } +} diff --git a/app/Models/Backtest.php b/app/Models/Backtest.php new file mode 100644 index 0000000..79554a7 --- /dev/null +++ b/app/Models/Backtest.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'features_json' => 'array', + 'coefficients_json' => 'array', + 'calibration_table' => 'array', + 'train_start' => 'date', + 'train_end' => 'date', + 'eval_start' => 'date', + 'eval_end' => 'date', + 'directional_accuracy' => 'decimal:2', + 'mae_pence' => 'decimal:2', + 'leak_suspected' => 'boolean', + 'ran_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ForecastOutcome.php b/app/Models/ForecastOutcome.php new file mode 100644 index 0000000..501a064 --- /dev/null +++ b/app/Models/ForecastOutcome.php @@ -0,0 +1,36 @@ + 'date', + 'correct' => 'boolean', + 'abs_error_pence' => 'integer', + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app/Models/LlmOverlay.php b/app/Models/LlmOverlay.php new file mode 100644 index 0000000..aa2a244 --- /dev/null +++ b/app/Models/LlmOverlay.php @@ -0,0 +1,35 @@ + 'datetime', + 'forecast_for_week' => 'date', + 'confidence' => 'integer', + 'events_json' => 'array', + 'agrees_with_ridge' => 'boolean', + 'major_impact_event' => 'boolean', + 'volatility_flag_on' => 'boolean', + 'search_used' => 'boolean', + ]; + } +} diff --git a/app/Models/VolatilityRegime.php b/app/Models/VolatilityRegime.php new file mode 100644 index 0000000..9abba7a --- /dev/null +++ b/app/Models/VolatilityRegime.php @@ -0,0 +1,30 @@ + 'datetime', + 'flipped_off_at' => 'datetime', + 'active' => 'boolean', + ]; + } + + public static function currentlyActive(): ?self + { + return static::query()->where('active', true)->orderByDesc('flipped_on_at')->first(); + } +} diff --git a/app/Models/WatchedEvent.php b/app/Models/WatchedEvent.php new file mode 100644 index 0000000..6531f03 --- /dev/null +++ b/app/Models/WatchedEvent.php @@ -0,0 +1,23 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + } +} diff --git a/app/Models/WeeklyForecast.php b/app/Models/WeeklyForecast.php new file mode 100644 index 0000000..48bf305 --- /dev/null +++ b/app/Models/WeeklyForecast.php @@ -0,0 +1,30 @@ + 'date', + 'magnitude_pence' => 'integer', + 'ridge_confidence' => 'integer', + 'flagged_duty_change' => 'boolean', + 'generated_at' => 'datetime', + ]; + } +} diff --git a/app/Services/BrentPriceFetcher.php b/app/Services/BrentPriceFetcher.php index b4bb901..80d2644 100644 --- a/app/Services/BrentPriceFetcher.php +++ b/app/Services/BrentPriceFetcher.php @@ -41,4 +41,24 @@ final readonly class BrentPriceFetcher BrentPrice::upsert($rows, ['date'], ['price_usd']); } + + /** + * One-shot Brent backfill via FRED's observation_start/end. Used to + * seed `brent_prices` going back to 2018 so Phase 9's volatility + * detector and Phase 8's LLM overlay have proper context. + * + * @return int rows inserted/updated + */ + public function backfillFromFred(string $from, string $to): int + { + $rows = $this->fred->fetchRange($from, $to); + + if ($rows === null) { + throw new BrentPriceFetchException("FRED backfill ({$from} → {$to}) returned no data"); + } + + BrentPrice::upsert($rows, ['date'], ['price_usd']); + + return count($rows); + } } diff --git a/app/Services/BrentPriceSources/FredBrentPriceSource.php b/app/Services/BrentPriceSources/FredBrentPriceSource.php index 779b38e..b270d1b 100644 --- a/app/Services/BrentPriceSources/FredBrentPriceSource.php +++ b/app/Services/BrentPriceSources/FredBrentPriceSource.php @@ -21,17 +21,50 @@ final class FredBrentPriceSource */ public function fetch(): ?array { + return $this->call([ + 'sort_order' => 'desc', + 'limit' => 30, + ]); + } + + /** + * Backfill range (inclusive). FRED's `observation_start` / + * `observation_end` parameters expect ISO dates (YYYY-MM-DD). + * Returns null when the range is empty (e.g. all weekends/holidays). + * + * @return array{date: string, price_usd: float}[]|null + * + * @throws BrentPriceFetchException + */ + public function fetchRange(string $from, string $to): ?array + { + return $this->call([ + 'observation_start' => $from, + 'observation_end' => $to, + 'sort_order' => 'asc', + 'limit' => 100000, + ]); + } + + /** + * @param array $extraParams + * @return array{date: string, price_usd: float}[]|null + * + * @throws BrentPriceFetchException + */ + private function call(array $extraParams): ?array + { + $params = array_merge([ + 'series_id' => 'DCOILBRENTEU', + 'api_key' => config('services.fred.api_key'), + 'file_type' => 'json', + ], $extraParams); + try { - $response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30) + $response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(60) ->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e)) ->throw() - ->get(self::URL, [ - 'series_id' => 'DCOILBRENTEU', - 'api_key' => config('services.fred.api_key'), - 'sort_order' => 'desc', - 'limit' => 30, - 'file_type' => 'json', - ])); + ->get(self::URL, $params)); } catch (ConnectionException $e) { throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e); } catch (RequestException $e) { diff --git a/app/Services/Forecasting/AccuracyHistory.php b/app/Services/Forecasting/AccuracyHistory.php new file mode 100644 index 0000000..cf56522 --- /dev/null +++ b/app/Services/Forecasting/AccuracyHistory.php @@ -0,0 +1,36 @@ +subWeeks(self::WEEKS)->toDateString(); + + $row = DB::table('forecast_outcomes') + ->where('model_version', $modelVersion) + ->where('forecast_for', '>=', $cutoff) + ->selectRaw('COUNT(*) as total, SUM(CASE WHEN correct THEN 1 ELSE 0 END) as correct') + ->first(); + + $total = (int) ($row->total ?? 0); + if ($total < self::MIN_OUTCOMES) { + return null; + } + + return (int) ($row->correct ?? 0) / $total; + } +} diff --git a/app/Services/Forecasting/BacktestRunner.php b/app/Services/Forecasting/BacktestRunner.php new file mode 100644 index 0000000..8b9e892 --- /dev/null +++ b/app/Services/Forecasting/BacktestRunner.php @@ -0,0 +1,162 @@ + 75). Primary leak defence is step 2. + */ +final class BacktestRunner +{ + private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L + + public function __construct( + private readonly LeakDetector $leakDetector = new LeakDetector, + ) {} + + public function run( + WeeklyForecastModel $model, + CarbonInterface $trainStart, + CarbonInterface $trainEnd, + CarbonInterface $evalStart, + CarbonInterface $evalEnd, + ): Backtest { + $trainingMondays = $this->mondaysBetween($trainStart, $trainEnd); + $evalMondays = $this->mondaysBetween($evalStart, $evalEnd); + + $spec = $model->featureSpec(); + $report = $this->leakDetector->validate($spec, [...$trainingMondays, ...$evalMondays]); + if ($report->hasLeaks()) { + throw new LeakDetectorException($report); + } + + $model->train($trainingMondays); + + $correct = 0; + $totalScored = 0; + $absErrors = []; + $bins = []; + + foreach ($evalMondays as $monday) { + $actualDelta = $this->actualDeltaPence($monday); + if ($actualDelta === null) { + continue; + } + + $prediction = $model->predict($monday); + $actualDirection = $this->classifyDirection($actualDelta); + $hit = $prediction->direction === $actualDirection; + + $totalScored++; + $absErrors[] = abs($prediction->magnitudePence - $actualDelta); + if ($hit) { + $correct++; + } + + $bin = $this->bucketForMagnitude($prediction->magnitudePence); + $bins[$bin] ??= ['correct' => 0, 'total' => 0]; + $bins[$bin]['total']++; + if ($hit) { + $bins[$bin]['correct']++; + } + } + + $directionalAccuracy = $totalScored === 0 + ? null + : round(($correct / $totalScored) * 100, 2); + + $maePence = $absErrors === [] + ? null + : round((array_sum($absErrors) / count($absErrors)) / 100, 2); + + $calibrationTable = []; + foreach ($bins as $key => $b) { + $calibrationTable[$key] = round($b['correct'] / $b['total'], 4); + } + + return Backtest::create([ + 'model_version' => $spec->modelVersion(), + 'features_json' => $spec->toArray(), + 'coefficients_json' => $model->coefficients(), + 'train_start' => $trainStart->toDateString(), + 'train_end' => $trainEnd->toDateString(), + 'eval_start' => $evalStart->toDateString(), + 'eval_end' => $evalEnd->toDateString(), + 'directional_accuracy' => $directionalAccuracy, + 'mae_pence' => $maePence, + 'calibration_table' => $calibrationTable, + 'leak_suspected' => $directionalAccuracy !== null && $directionalAccuracy > 75.0, + 'ran_at' => now(), + ]); + } + + /** @return array */ + private function mondaysBetween(CarbonInterface $start, CarbonInterface $end): array + { + $mondays = []; + $cursor = $start->copy()->startOfDay(); + $boundary = $end->copy()->startOfDay(); + + while ($cursor->lessThanOrEqualTo($boundary)) { + if ($cursor->dayOfWeek === CarbonInterface::MONDAY) { + $mondays[] = $cursor->copy(); + } + $cursor = $cursor->addDay(); + } + + return $mondays; + } + + private function actualDeltaPence(CarbonInterface $targetMonday): ?float + { + $current = DB::table('weekly_pump_prices') + ->where('date', $targetMonday->toDateString()) + ->value('ulsp_pence'); + $previous = DB::table('weekly_pump_prices') + ->where('date', $targetMonday->copy()->subDays(7)->toDateString()) + ->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', + }; + } + + 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+', + }; + } +} diff --git a/app/Services/Forecasting/BeisImporter.php b/app/Services/Forecasting/BeisImporter.php new file mode 100644 index 0000000..38222e3 --- /dev/null +++ b/app/Services/Forecasting/BeisImporter.php @@ -0,0 +1,138 @@ +resolveCsvUrl(); + $csv = $this->downloadCsv($url); + $rows = $this->parse($csv); + + if ($rows === []) { + throw new RuntimeException('BEIS CSV parsed empty — check delimiter / encoding'); + } + + DB::table('weekly_pump_prices')->upsert( + $rows, + ['date'], + ['ulsp_pence', 'ulsd_pence', 'ulsp_duty_pence', 'ulsd_duty_pence', 'ulsp_vat_pct', 'ulsd_vat_pct'], + ); + + Cache::flush(); + + $latest = (string) collect($rows)->pluck('date')->sortDesc()->first(); + + return [ + 'csv_url' => $url, + 'parsed' => count($rows), + 'upserted' => count($rows), + 'latest_date' => $latest, + ]; + } + + private function resolveCsvUrl(): string + { + $response = Http::timeout(15)->acceptJson()->get(self::API_URL); + $response->throw(); + + $attachments = $response->json('details.attachments', []); + foreach ($attachments as $a) { + if (($a['title'] ?? null) === self::ATTACHMENT_TITLE) { + $url = $a['url'] ?? null; + if (! is_string($url) || $url === '') { + throw new RuntimeException('BEIS attachment had empty URL'); + } + + return $url; + } + } + + throw new RuntimeException(sprintf( + 'gov.uk content API did not return an attachment titled %s', + self::ATTACHMENT_TITLE, + )); + } + + private function downloadCsv(string $url): string + { + $response = Http::timeout(60)->get($url); + $response->throw(); + + return $response->body(); + } + + /** + * @return array> + */ + private function parse(string $csv): array + { + $rows = []; + $lines = preg_split('/\r\n|\r|\n/', $csv); + if ($lines === false || count($lines) < 2) { + return []; + } + + // Skip header. + array_shift($lines); + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $cols = str_getcsv($line, escape: '\\'); + if (count($cols) < 7) { + continue; + } + + $date = DateTime::createFromFormat('d/m/Y', trim($cols[0])); + if ($date === false) { + continue; + } + + $rows[] = [ + 'date' => $date->format('Y-m-d'), + 'ulsp_pence' => (int) round(((float) $cols[1]) * 100), + 'ulsd_pence' => (int) round(((float) $cols[2]) * 100), + 'ulsp_duty_pence' => (int) round(((float) $cols[3]) * 100), + 'ulsd_duty_pence' => (int) round(((float) $cols[4]) * 100), + 'ulsp_vat_pct' => (int) $cols[5], + 'ulsd_vat_pct' => (int) $cols[6], + ]; + } + + return $rows; + } +} diff --git a/app/Services/Forecasting/Contracts/ForecastFeature.php b/app/Services/Forecasting/Contracts/ForecastFeature.php new file mode 100644 index 0000000..ed70c6a --- /dev/null +++ b/app/Services/Forecasting/Contracts/ForecastFeature.php @@ -0,0 +1,33 @@ + + */ + public function sourceDates(CarbonInterface $targetMonday): array; +} diff --git a/app/Services/Forecasting/Contracts/WeeklyForecastModel.php b/app/Services/Forecasting/Contracts/WeeklyForecastModel.php new file mode 100644 index 0000000..8ad23e7 --- /dev/null +++ b/app/Services/Forecasting/Contracts/WeeklyForecastModel.php @@ -0,0 +1,40 @@ + $trainingMondays + */ + public function train(array $trainingMondays): void; + + /** + * Predict ΔULSP for the week starting $targetMonday. Returned value + * is in pence × 100 (integer-ish, but typed float for fractional + * predictions). + */ + public function predict(CarbonInterface $targetMonday): WeeklyPrediction; + + /** + * Coefficients in a JSON-serialisable form, or null for non-parametric + * models like the naive baseline. + * + * @return array|null + */ + public function coefficients(): ?array; +} diff --git a/app/Services/Forecasting/DutyChangeDetector.php b/app/Services/Forecasting/DutyChangeDetector.php new file mode 100644 index 0000000..75849e0 --- /dev/null +++ b/app/Services/Forecasting/DutyChangeDetector.php @@ -0,0 +1,45 @@ +copy()->subWeeks(self::FLAG_RADIUS_WEEKS)->toDateString(); + $end = $targetMonday->copy()->addWeeks(self::FLAG_RADIUS_WEEKS)->toDateString(); + + $rows = DB::table('weekly_pump_prices') + ->whereBetween('date', [$start, $end]) + ->orderBy('date') + ->get(['date', 'ulsp_duty_pence']); + + if ($rows->count() < 2) { + return false; + } + + $previous = null; + foreach ($rows as $r) { + if ($previous !== null && (int) $r->ulsp_duty_pence !== $previous) { + return true; + } + $previous = (int) $r->ulsp_duty_pence; + } + + return false; + } +} diff --git a/app/Services/Forecasting/FeatureSpec.php b/app/Services/Forecasting/FeatureSpec.php new file mode 100644 index 0000000..144f4c1 --- /dev/null +++ b/app/Services/Forecasting/FeatureSpec.php @@ -0,0 +1,54 @@ + $features */ + public function __construct( + public string $modelLabel, + public array $features, + ) { + foreach ($features as $f) { + if (! $f instanceof ForecastFeature) { + throw new InvalidArgumentException('Every spec entry must implement ForecastFeature'); + } + } + } + + /** @return array */ + public function names(): array + { + return array_map(fn (ForecastFeature $f): string => $f->name(), $this->features); + } + + public function modelVersion(): string + { + $names = $this->names(); + sort($names); + $hash = substr(sha1(json_encode($names, JSON_THROW_ON_ERROR)), 0, 12); + + return $this->modelLabel.'-'.$hash; + } + + /** @return array{model_label: string, features: array} */ + public function toArray(): array + { + return [ + 'model_label' => $this->modelLabel, + 'features' => $this->names(), + ]; + } +} diff --git a/app/Services/Forecasting/Features/DeltaUlsdLag.php b/app/Services/Forecasting/Features/DeltaUlsdLag.php new file mode 100644 index 0000000..cd7b32a --- /dev/null +++ b/app/Services/Forecasting/Features/DeltaUlsdLag.php @@ -0,0 +1,50 @@ +lag; + } + + public function valueFor(CarbonInterface $targetMonday): ?float + { + [$newer, $older] = $this->dates($targetMonday); + $a = $this->loader->ulsdPence($newer->toDateString()); + $b = $this->loader->ulsdPence($older->toDateString()); + if ($a === null || $b === null) { + return null; + } + + return (float) ($a - $b); + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + return $this->dates($targetMonday); + } + + /** @return array{0: CarbonInterface, 1: CarbonInterface} */ + private function dates(CarbonInterface $targetMonday): array + { + return [ + $targetMonday->copy()->subDays(7 * ($this->lag + 1)), + $targetMonday->copy()->subDays(7 * ($this->lag + 2)), + ]; + } +} diff --git a/app/Services/Forecasting/Features/DeltaUlspLag.php b/app/Services/Forecasting/Features/DeltaUlspLag.php new file mode 100644 index 0000000..6222c5b --- /dev/null +++ b/app/Services/Forecasting/Features/DeltaUlspLag.php @@ -0,0 +1,57 @@ +lag; + } + + public function valueFor(CarbonInterface $targetMonday): ?float + { + [$newer, $older] = $this->dates($targetMonday); + $a = $this->loader->ulspPence($newer->toDateString()); + $b = $this->loader->ulspPence($older->toDateString()); + if ($a === null || $b === null) { + return null; + } + + return (float) ($a - $b); + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + return $this->dates($targetMonday); + } + + /** @return array{0: CarbonInterface, 1: CarbonInterface} */ + private function dates(CarbonInterface $targetMonday): array + { + return [ + $targetMonday->copy()->subDays(7 * ($this->lag + 1)), + $targetMonday->copy()->subDays(7 * ($this->lag + 2)), + ]; + } +} diff --git a/app/Services/Forecasting/Features/IsPreBankHoliday.php b/app/Services/Forecasting/Features/IsPreBankHoliday.php new file mode 100644 index 0000000..d29b633 --- /dev/null +++ b/app/Services/Forecasting/Features/IsPreBankHoliday.php @@ -0,0 +1,32 @@ +sourceDates($targetMonday) as $d) { + $v = $this->loader->ulspPence($d->toDateString()); + if ($v === null) { + return null; + } + $values[] = (float) $v; + } + + $latest = $values[0]; + $mean = array_sum($values) / count($values); + + return $latest - $mean; + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + $dates = []; + for ($w = 1; $w <= self::WINDOW_WEEKS; $w++) { + $dates[] = $targetMonday->copy()->subDays(7 * $w); + } + + return $dates; + } +} diff --git a/app/Services/Forecasting/Features/WeekOfYearTrig.php b/app/Services/Forecasting/Features/WeekOfYearTrig.php new file mode 100644 index 0000000..a47edca --- /dev/null +++ b/app/Services/Forecasting/Features/WeekOfYearTrig.php @@ -0,0 +1,43 @@ +component; + } + + public function valueFor(CarbonInterface $targetMonday): ?float + { + $week = (int) $targetMonday->format('W'); // ISO week number 1..53 + $angle = 2.0 * M_PI * $week / 52.0; + + return $this->component === 'sin' ? sin($angle) : cos($angle); + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + return []; + } +} diff --git a/app/Services/Forecasting/LeakDetector.php b/app/Services/Forecasting/LeakDetector.php new file mode 100644 index 0000000..d4c8113 --- /dev/null +++ b/app/Services/Forecasting/LeakDetector.php @@ -0,0 +1,41 @@ +75% smell test on + * the resulting backtest is a secondary check. + */ +final class LeakDetector +{ + /** @param array $trainingMondays */ + public function validate(FeatureSpec $spec, array $trainingMondays): LeakReport + { + $leaks = []; + + foreach ($trainingMondays as $target) { + foreach ($spec->features as $feature) { + foreach ($feature->sourceDates($target) as $source) { + if ($source->greaterThanOrEqualTo($target)) { + $leaks[] = [ + 'feature' => $feature->name(), + 'target_monday' => $target->toDateString(), + 'source_date' => $source->toDateString(), + ]; + } + } + } + } + + return new LeakReport($leaks); + } +} diff --git a/app/Services/Forecasting/LeakDetectorException.php b/app/Services/Forecasting/LeakDetectorException.php new file mode 100644 index 0000000..fd9d939 --- /dev/null +++ b/app/Services/Forecasting/LeakDetectorException.php @@ -0,0 +1,19 @@ +leaks); + $first = $report->leaks[0] ?? null; + $sample = $first === null + ? '' + : sprintf(' First: feature "%s" reads %s for target %s.', $first['feature'], $first['source_date'], $first['target_monday']); + + parent::__construct(sprintf('Structural time leak detected in %d feature value(s).%s', $count, $sample)); + } +} diff --git a/app/Services/Forecasting/LeakReport.php b/app/Services/Forecasting/LeakReport.php new file mode 100644 index 0000000..0e72657 --- /dev/null +++ b/app/Services/Forecasting/LeakReport.php @@ -0,0 +1,20 @@ + $leaks */ + public function __construct(public array $leaks) {} + + public function hasLeaks(): bool + { + return $this->leaks !== []; + } +} diff --git a/app/Services/Forecasting/LinearAlgebra.php b/app/Services/Forecasting/LinearAlgebra.php new file mode 100644 index 0000000..22c1517 --- /dev/null +++ b/app/Services/Forecasting/LinearAlgebra.php @@ -0,0 +1,200 @@ +>. Vectors are array. + * Sized for the v1 ridge model (435 × 8); Gauss–Jordan with partial + * pivoting is plenty for inverting the 8 × 8 normal-equation matrix. + */ +final class LinearAlgebra +{ + /** + * Transpose. m is rows × cols → result is cols × rows. + * + * @param array> $m + * @return array> + */ + public static function transpose(array $m): array + { + $rows = count($m); + if ($rows === 0) { + return []; + } + $cols = count($m[0]); + $out = array_fill(0, $cols, array_fill(0, $rows, 0.0)); + for ($i = 0; $i < $rows; $i++) { + for ($j = 0; $j < $cols; $j++) { + $out[$j][$i] = $m[$i][$j]; + } + } + + return $out; + } + + /** + * Matrix multiply. a (r×k) * b (k×c) → r×c. + * + * @param array> $a + * @param array> $b + * @return array> + */ + public static function multiply(array $a, array $b): array + { + $r = count($a); + $k = count($a[0] ?? []); + $c = count($b[0] ?? []); + if (count($b) !== $k) { + throw new InvalidArgumentException('Matrix multiply dimension mismatch'); + } + $out = array_fill(0, $r, array_fill(0, $c, 0.0)); + for ($i = 0; $i < $r; $i++) { + for ($j = 0; $j < $c; $j++) { + $sum = 0.0; + for ($p = 0; $p < $k; $p++) { + $sum += $a[$i][$p] * $b[$p][$j]; + } + $out[$i][$j] = $sum; + } + } + + return $out; + } + + /** + * Matrix × vector. a (r×k) * v (k) → r-vector. + * + * @param array> $a + * @param array $v + * @return array + */ + public static function multiplyVector(array $a, array $v): array + { + $r = count($a); + $k = count($v); + if (count($a[0] ?? []) !== $k) { + throw new InvalidArgumentException('Matrix × vector dimension mismatch'); + } + $out = array_fill(0, $r, 0.0); + for ($i = 0; $i < $r; $i++) { + $sum = 0.0; + for ($p = 0; $p < $k; $p++) { + $sum += $a[$i][$p] * $v[$p]; + } + $out[$i] = $sum; + } + + return $out; + } + + /** + * Identity matrix of size n. + * + * @return array> + */ + public static function identity(int $n): array + { + $out = array_fill(0, $n, array_fill(0, $n, 0.0)); + for ($i = 0; $i < $n; $i++) { + $out[$i][$i] = 1.0; + } + + return $out; + } + + /** + * Solve A x = b using Gauss–Jordan elimination with partial pivoting. + * A is square n×n. Returns x as an n-vector. + * + * @param array> $A + * @param array $b + * @return array + */ + public static function solve(array $A, array $b): array + { + $n = count($A); + if (count($b) !== $n) { + throw new InvalidArgumentException('solve: RHS dimension mismatch'); + } + // Build augmented matrix. + $aug = []; + for ($i = 0; $i < $n; $i++) { + $aug[$i] = array_merge($A[$i], [$b[$i]]); + } + + for ($col = 0; $col < $n; $col++) { + // Partial pivot: find row with largest |value| in this column. + $pivot = $col; + $best = abs($aug[$col][$col]); + for ($r = $col + 1; $r < $n; $r++) { + $v = abs($aug[$r][$col]); + if ($v > $best) { + $best = $v; + $pivot = $r; + } + } + if ($best < 1e-12) { + throw new RuntimeException('solve: matrix is singular or near-singular'); + } + if ($pivot !== $col) { + [$aug[$col], $aug[$pivot]] = [$aug[$pivot], $aug[$col]]; + } + // Normalise pivot row. + $div = $aug[$col][$col]; + for ($j = 0; $j <= $n; $j++) { + $aug[$col][$j] /= $div; + } + // Eliminate this column from every other row. + for ($r = 0; $r < $n; $r++) { + if ($r === $col) { + continue; + } + $factor = $aug[$r][$col]; + if ($factor === 0.0) { + continue; + } + for ($j = 0; $j <= $n; $j++) { + $aug[$r][$j] -= $factor * $aug[$col][$j]; + } + } + } + + $x = array_fill(0, $n, 0.0); + for ($i = 0; $i < $n; $i++) { + $x[$i] = $aug[$i][$n]; + } + + return $x; + } + + /** + * Ridge solve: β = (XᵀX + λI) ⁻¹ Xᵀy. + * + * λ is applied to all coefficients. Caller should standardise X and + * centre y before calling, then add intercept back externally — the + * intercept must NOT be regularised. + * + * @param array> $X + * @param array $y + * @return array + */ + public static function ridgeSolve(array $X, array $y, float $lambda): array + { + $Xt = self::transpose($X); + $XtX = self::multiply($Xt, $X); + + $n = count($XtX); + for ($i = 0; $i < $n; $i++) { + $XtX[$i][$i] += $lambda; + } + + $Xty = self::multiplyVector($Xt, $y); + + return self::solve($XtX, $Xty); + } +} diff --git a/app/Services/Forecasting/LlmOverlayService.php b/app/Services/Forecasting/LlmOverlayService.php new file mode 100644 index 0000000..1c776f7 --- /dev/null +++ b/app/Services/Forecasting/LlmOverlayService.php @@ -0,0 +1,374 @@ +apiKey() === null) { + Log::info('LlmOverlayService: no ANTHROPIC_API_KEY, skipping'); + + return null; + } + + if ($eventDriven && $this->onCooldown()) { + return null; + } + + $forecast = $this->weeklyForecast->currentForecast(); + $context = $this->buildContext($forecast); + + $rawResult = $this->callAnthropic($context); + if ($rawResult === null) { + return null; + } + + $verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []); + if ($verifiedEvents === []) { + Log::warning('LlmOverlayService: no verified citations, rejecting overlay'); + + return null; + } + + $confidence = max(0, min(self::CONFIDENCE_CAP, (int) ($rawResult['confidence'] ?? 0))); + $direction = $rawResult['direction'] ?? 'flat'; + $agreesWithRidge = $direction === $this->ridgeDirection($forecast['predicted_direction']); + + return LlmOverlay::query()->create([ + 'ran_at' => now(), + 'forecast_for_week' => $this->upcomingMondayDateString(), + 'direction' => $direction, + 'confidence' => $confidence, + 'reasoning' => (string) ($rawResult['reasoning_short'] ?? ''), + 'events_json' => $verifiedEvents, + 'agrees_with_ridge' => $agreesWithRidge, + 'major_impact_event' => (bool) ($rawResult['major_impact_event'] ?? false), + 'volatility_flag_on' => VolatilityRegime::currentlyActive() !== null, + 'search_used' => true, + ]); + } + + private function onCooldown(): bool + { + $latest = LlmOverlay::query()->orderByDesc('ran_at')->first(); + + return $latest !== null + && $latest->ran_at->greaterThanOrEqualTo(now()->subHours(self::COOLDOWN_HOURS)); + } + + /** @return array */ + private function buildContext(array $forecast): array + { + $ulspWeekly = DB::table('weekly_pump_prices') + ->orderByDesc('date') + ->limit(8) + ->get(['date', 'ulsp_pence']) + ->reverse() + ->map(fn ($r): array => ['date' => (string) $r->date, 'ulsp_pence' => round((int) $r->ulsp_pence / 100, 1)]) + ->values() + ->all(); + + $brentRecent = BrentPrice::query() + ->orderByDesc('date') + ->limit(14) + ->get(['date', 'price_usd']) + ->reverse() + ->map(fn (BrentPrice $r): array => ['date' => (string) $r->date->toDateString(), 'price_usd' => (float) $r->price_usd]) + ->values() + ->all(); + + return [ + 'ulsp_recent_8_weeks' => $ulspWeekly, + 'brent_recent_14_days' => $brentRecent, + 'ridge_model_says' => [ + 'direction' => $forecast['predicted_direction'] ?? 'stable', + 'confidence' => $forecast['confidence_score'] ?? 0, + 'magnitude_pence' => $forecast['predicted_change_pence'] ?? 0, + ], + ]; + } + + /** @return array|null */ + private function callAnthropic(array $context): ?array + { + $messages = [['role' => 'user', 'content' => $this->prompt($context)]]; + + try { + // Phase 1: web search loop + for ($i = 0, $response = null; $i < 5; $i++) { + $response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45) + ->withHeaders($this->headers()) + ->post(self::URL, [ + 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), + 'max_tokens' => 1024, + 'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']], + 'messages' => $messages, + ])); + + if (! $response->successful()) { + Log::error('LlmOverlayService: search request failed', ['status' => $response->status()]); + + return null; + } + + if ($response->json('stop_reason') !== 'pause_turn') { + break; + } + + $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; + } + + $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; + $messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.']; + + // Phase 2: forced structured output + $submitResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20) + ->withHeaders($this->headers()) + ->post(self::URL, [ + 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), + 'max_tokens' => 512, + 'tools' => [$this->submitOverlayTool()], + 'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'], + 'messages' => $messages, + ])); + + if (! $submitResponse->successful()) { + Log::error('LlmOverlayService: submit request failed', ['status' => $submitResponse->status()]); + + return null; + } + + return $this->extractToolInput($submitResponse->json('content') ?? []); + } catch (Throwable $e) { + Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]); + + return null; + } + } + + private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)'; + + /** + * Verify each cited URL is reachable. Major news sites (Reuters, FT, + * Bloomberg, BBC...) often reject HEAD with 403 / 405 even though + * GET works fine. So: try HEAD first, then fall back to a 1-byte + * GET (Range header) when HEAD fails. Both must include a + * browser-shaped User-Agent or Cloudflare etc. block us as a bot. + * + * Every URL — verified or rejected — is logged at INFO/WARNING so + * operators can debug rejections from `storage/logs/laravel.log` + * without needing to capture the Anthropic response body. + * + * @param array> $events + * @return array> + */ + private function verifyCitedUrls(array $events): array + { + $verified = []; + foreach ($events as $event) { + $url = (string) ($event['url'] ?? ''); + if ($url === '') { + Log::warning('LlmOverlayService: dropping cited event with empty URL', [ + 'headline' => $event['headline'] ?? null, + 'source' => $event['source'] ?? null, + ]); + + continue; + } + [$reachable, $diagnosis] = $this->urlReachable($url); + if ($reachable) { + Log::info('LlmOverlayService: URL verified', [ + 'url' => $url, + 'via' => $diagnosis, + ]); + $verified[] = $event; + } else { + Log::warning('LlmOverlayService: URL rejected', [ + 'url' => $url, + 'reason' => $diagnosis, + 'headline' => $event['headline'] ?? null, + 'source' => $event['source'] ?? null, + ]); + } + } + + return $verified; + } + + /** @return array{0: bool, 1: string} [reachable, diagnostic_string] */ + private function urlReachable(string $url): array + { + $headers = ['User-Agent' => self::VERIFICATION_USER_AGENT]; + $headStatus = 'no-attempt'; + + try { + $head = Http::timeout(5) + ->withHeaders($headers) + ->head($url); + $headStatus = 'HEAD='.$head->status(); + if ($head->successful() || $head->redirect()) { + return [true, $headStatus]; + } + } catch (Throwable $e) { + $headStatus = 'HEAD=exception('.class_basename($e).')'; + } + + try { + $get = Http::timeout(8) + ->withHeaders($headers + ['Range' => 'bytes=0-0']) + ->get($url); + $getStatus = 'GET='.$get->status(); + if ($get->successful() || $get->redirect()) { + return [true, $headStatus.' → '.$getStatus.' (fallback)']; + } + + return [false, $headStatus.' → '.$getStatus]; + } catch (Throwable $e) { + return [false, $headStatus.' → GET=exception('.class_basename($e).')']; + } + } + + private function ridgeDirection(string $publicDirection): string + { + return match ($publicDirection) { + 'up' => 'rising', + 'down' => 'falling', + default => 'flat', + }; + } + + private function upcomingMondayDateString(): string + { + $today = now()->startOfDay(); + $monday = $today->isMonday() ? $today : $today->copy()->next(CarbonInterface::MONDAY); + + return $monday->toDateString(); + } + + /** @return array */ + private function headers(): array + { + return [ + 'x-api-key' => $this->apiKey(), + 'anthropic-version' => '2023-06-01', + ]; + } + + private function apiKey(): ?string + { + return config('services.anthropic.api_key'); + } + + private function prompt(array $context): string + { + $json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return <<confidenceCap), short reasoning, cited events with URLs, + agrees_with_ridge, and major_impact_event. + + Citing events with REAL URLs is mandatory. An empty citation array will be + rejected and the overlay discarded. + PROMPT; + } + + private string $confidenceCap = '75'; + + /** @return array */ + private function submitOverlayTool(): array + { + return [ + 'name' => 'submit_overlay', + 'description' => 'Submit the news-aware overlay for the upcoming weekly forecast.', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']], + 'confidence' => ['type' => 'integer', 'minimum' => 0, 'maximum' => self::CONFIDENCE_CAP], + 'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'], + 'events_cited' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'headline' => ['type' => 'string'], + 'source' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'impact' => ['type' => 'string', 'enum' => ['rising', 'falling', 'neutral']], + ], + 'required' => ['headline', 'source', 'url', 'impact'], + ], + ], + 'agrees_with_ridge' => ['type' => 'boolean'], + 'major_impact_event' => ['type' => 'boolean'], + ], + 'required' => ['direction', 'confidence', 'reasoning_short', 'events_cited', 'agrees_with_ridge', 'major_impact_event'], + ], + ]; + } + + /** + * @param array $content + * @return array|null + */ + private function extractToolInput(array $content): ?array + { + $block = collect($content)->firstWhere('type', 'tool_use'); + + return $block['input'] ?? null; + } +} diff --git a/app/Services/Forecasting/LocalSnapshotService.php b/app/Services/Forecasting/LocalSnapshotService.php new file mode 100644 index 0000000..ef63f89 --- /dev/null +++ b/app/Services/Forecasting/LocalSnapshotService.php @@ -0,0 +1,147 @@ +, + * supermarket_avg_pence: ?float, + * major_avg_pence: ?float, + * supermarket_gap_pence: ?float, + * stations_within_radius: int + * } + */ + public function snapshot(string $fuelType, float $lat, float $lng, int $radiusKm = 25): array + { + $nationalAvg = $this->nationalAverage($fuelType); + $localAvg = $this->localAverage($fuelType, $lat, $lng, 50); + $cheapest = $this->cheapestNearby($fuelType, $lat, $lng, $radiusKm, 5); + [$superAvg, $majorAvg] = $this->brandSplit($fuelType, $lat, $lng, $radiusKm); + $stationCount = $this->stationCountWithin($fuelType, $lat, $lng, $radiusKm); + + return [ + 'national_avg_pence' => $nationalAvg, + 'local_avg_pence' => $localAvg, + 'local_minus_national_pence' => $localAvg !== null && $nationalAvg !== null + ? round($localAvg - $nationalAvg, 1) + : null, + 'cheapest_nearby' => $cheapest, + 'supermarket_avg_pence' => $superAvg, + 'major_avg_pence' => $majorAvg, + 'supermarket_gap_pence' => $superAvg !== null && $majorAvg !== null + ? round($superAvg - $majorAvg, 1) + : null, + 'stations_within_radius' => $stationCount, + ]; + } + + private function nationalAverage(string $fuelType): ?float + { + $avg = DB::table('station_prices_current') + ->where('fuel_type', $fuelType) + ->avg('price_pence'); + + return $avg === null ? null : round((float) $avg / 100, 1); + } + + private function localAverage(string $fuelType, float $lat, float $lng, int $km): ?float + { + [$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km); + + $avg = DB::table('station_prices_current') + ->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id') + ->where('station_prices_current.fuel_type', $fuelType) + ->whereRaw($within, $bindings) + ->avg('station_prices_current.price_pence'); + + return $avg === null ? null : round((float) $avg / 100, 1); + } + + /** + * @return array + */ + private function cheapestNearby(string $fuelType, float $lat, float $lng, int $km, int $limit): array + { + [$distance, $distanceBindings] = HaversineQuery::distanceKm($lat, $lng); + [$within, $withinBindings] = HaversineQuery::withinKm($lat, $lng, $km); + + $rows = DB::table('station_prices_current') + ->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id') + ->where('station_prices_current.fuel_type', $fuelType) + ->whereRaw($within, $withinBindings) + ->selectRaw( + 'stations.node_id, stations.trading_name as name, stations.brand_name as brand, ' + .'station_prices_current.price_pence, '.$distance.' as distance_km', + $distanceBindings, + ) + ->orderBy('station_prices_current.price_pence') + ->limit($limit) + ->get(); + + return $rows->map(fn ($r): array => [ + 'node_id' => (string) $r->node_id, + 'name' => $r->name === null ? null : (string) $r->name, + 'brand' => $r->brand === null ? null : (string) $r->brand, + 'price_pence' => (int) $r->price_pence, + 'distance_km' => round((float) $r->distance_km, 2), + ])->all(); + } + + /** @return array{0: ?float, 1: ?float} [supermarket_avg, major_avg] */ + private function brandSplit(string $fuelType, float $lat, float $lng, int $km): array + { + [$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km); + + $rows = DB::table('station_prices_current') + ->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id') + ->where('station_prices_current.fuel_type', $fuelType) + ->whereRaw($within, $bindings) + ->selectRaw('stations.is_supermarket, AVG(station_prices_current.price_pence) as avg_pence') + ->groupBy('stations.is_supermarket') + ->get(); + + $super = null; + $major = null; + foreach ($rows as $r) { + $avg = round((float) $r->avg_pence / 100, 1); + if ((int) $r->is_supermarket === 1) { + $super = $avg; + } else { + $major = $avg; + } + } + + return [$super, $major]; + } + + private function stationCountWithin(string $fuelType, float $lat, float $lng, int $km): int + { + [$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km); + + return DB::table('station_prices_current') + ->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id') + ->where('station_prices_current.fuel_type', $fuelType) + ->whereRaw($within, $bindings) + ->count(); + } +} diff --git a/app/Services/Forecasting/Models/NaiveZeroChangeModel.php b/app/Services/Forecasting/Models/NaiveZeroChangeModel.php new file mode 100644 index 0000000..8ca4acd --- /dev/null +++ b/app/Services/Forecasting/Models/NaiveZeroChangeModel.php @@ -0,0 +1,39 @@ + FLAT_THRESHOLD_PENCE_X100 + * - falling if magnitude < −FLAT_THRESHOLD_PENCE_X100 + * - flat otherwise + */ +final class RidgeRegressionModel implements WeeklyForecastModel +{ + private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L + + /** @var array|null Coefficients on standardised features (no intercept). */ + private ?array $beta = null; + + private ?float $intercept = null; + + /** @var array|null per-feature mean used for standardisation */ + private ?array $featureMeans = null; + + /** @var array|null per-feature std-dev used for standardisation */ + private ?array $featureStdDevs = null; + + public function __construct( + private readonly FeatureSpec $spec, + private readonly WeeklyPumpPriceLoader $loader, + public readonly float $lambda = 1.0, + ) {} + + public function featureSpec(): FeatureSpec + { + return $this->spec; + } + + public function train(array $trainingMondays): void + { + $X = []; + $y = []; + + foreach ($trainingMondays as $monday) { + $row = []; + $skip = false; + foreach ($this->spec->features as $feature) { + $v = $feature->valueFor($monday); + if ($v === null) { + $skip = true; + break; + } + $row[] = $v; + } + if ($skip) { + continue; + } + + $actual = $this->actualDeltaPence($monday); + if ($actual === null) { + continue; + } + + $X[] = $row; + $y[] = $actual; + } + + if (count($X) < count($this->spec->features) + 2) { + throw new RuntimeException('RidgeRegressionModel: insufficient training rows after dropping incomplete weeks'); + } + + // Standardise X (z-score) and centre y. + $featureCount = count($X[0]); + $means = array_fill(0, $featureCount, 0.0); + $stds = array_fill(0, $featureCount, 0.0); + $n = count($X); + + for ($j = 0; $j < $featureCount; $j++) { + $col = array_column($X, $j); + $means[$j] = array_sum($col) / $n; + $variance = 0.0; + foreach ($col as $v) { + $variance += ($v - $means[$j]) ** 2; + } + $variance /= $n; + $stds[$j] = sqrt($variance); + // Constant features get sd=1 so we don't divide by zero. Their + // contribution is then a constant absorbed by the intercept. + if ($stds[$j] < 1e-12) { + $stds[$j] = 1.0; + } + } + + $Xstd = []; + foreach ($X as $row) { + $r = []; + for ($j = 0; $j < $featureCount; $j++) { + $r[] = ($row[$j] - $means[$j]) / $stds[$j]; + } + $Xstd[] = $r; + } + + $yMean = array_sum($y) / $n; + $yCentred = array_map(fn (float $v): float => $v - $yMean, $y); + + $this->beta = LinearAlgebra::ridgeSolve($Xstd, $yCentred, $this->lambda); + $this->intercept = $yMean; + $this->featureMeans = $means; + $this->featureStdDevs = $stds; + } + + public function predict(CarbonInterface $targetMonday): WeeklyPrediction + { + if ($this->beta === null) { + throw new RuntimeException('RidgeRegressionModel: predict() called before train()'); + } + + $row = []; + foreach ($this->spec->features as $feature) { + $v = $feature->valueFor($targetMonday); + if ($v === null) { + return new WeeklyPrediction($targetMonday, 0.0, 'flat'); + } + $row[] = $v; + } + + $magnitude = $this->intercept; + for ($j = 0, $jc = count($row); $j < $jc; $j++) { + $z = ($row[$j] - $this->featureMeans[$j]) / $this->featureStdDevs[$j]; + $magnitude += $z * $this->beta[$j]; + } + + return new WeeklyPrediction($targetMonday, $magnitude, $this->classifyDirection($magnitude)); + } + + public function coefficients(): ?array + { + if ($this->beta === null) { + return null; + } + + $named = []; + foreach ($this->spec->features as $i => $feature) { + $named[$feature->name()] = [ + 'beta_standardised' => $this->beta[$i], + 'mean' => $this->featureMeans[$i], + 'std_dev' => $this->featureStdDevs[$i], + ]; + } + + return [ + 'intercept' => $this->intercept, + 'lambda' => $this->lambda, + 'features' => $named, + ]; + } + + private function actualDeltaPence(CarbonInterface $targetMonday): ?float + { + $current = $this->loader->ulspPence($targetMonday->toDateString()); + $previous = $this->loader->ulspPence($targetMonday->copy()->subDays(7)->toDateString()); + + if ($current === null || $previous === null) { + return null; + } + + return (float) ($current - $previous); + } + + private function classifyDirection(float $magnitude): string + { + return match (true) { + $magnitude > self::FLAT_THRESHOLD_PENCE_X100 => 'rising', + $magnitude < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling', + default => 'flat', + }; + } +} diff --git a/app/Services/Forecasting/OutcomeResolver.php b/app/Services/Forecasting/OutcomeResolver.php new file mode 100644 index 0000000..f252c06 --- /dev/null +++ b/app/Services/Forecasting/OutcomeResolver.php @@ -0,0 +1,87 @@ +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', + }; + } +} diff --git a/app/Services/Forecasting/ReasoningGenerator.php b/app/Services/Forecasting/ReasoningGenerator.php new file mode 100644 index 0000000..e42dae3 --- /dev/null +++ b/app/Services/Forecasting/ReasoningGenerator.php @@ -0,0 +1,103 @@ + */ + private const array PHRASES = [ + 'delta_ulsp_lag_0' => "last week's pump price move", + 'delta_ulsp_lag_1' => 'the pump price move two weeks ago', + 'delta_ulsp_lag_3' => 'the pump price move four weeks ago', + 'delta_ulsd_lag_0' => "last week's diesel move", + 'ulsp_minus_ma8' => "the gap between this week's pump price and its 8-week average", + 'week_of_year_sin' => 'the seasonal pattern', + 'week_of_year_cos' => 'the seasonal pattern', + 'is_pre_bank_holiday' => 'an upcoming bank holiday', + ]; + + /** + * @param array $features + */ + public function generate( + RidgeRegressionModel $model, + WeeklyPrediction $prediction, + array $features, + CarbonInterface $targetMonday, + int $confidence, + bool $flaggedDutyChange, + ?float $trailingHitRate, + ): string { + if ($confidence < 40) { + return 'Not enough signal in the historical pattern to call this week — staying silent.'; + } + + $coeffs = $model->coefficients() ?? []; + $features_meta = $coeffs['features'] ?? []; + + $contributions = []; + foreach ($features as $f) { + $name = $f->name(); + $meta = $features_meta[$name] ?? null; + if ($meta === null) { + continue; + } + $value = $f->valueFor($targetMonday); + if ($value === null) { + continue; + } + $z = ($value - $meta['mean']) / ($meta['std_dev'] ?: 1.0); + $contributions[$name] = $z * $meta['beta_standardised']; + } + + $headline = $this->headline($prediction); + $driver = $this->dominantFeatureSentence($contributions); + $duty = $flaggedDutyChange + ? ' Recent fuel duty change may skew accuracy for the next several weeks.' + : ''; + $accuracy = $trailingHitRate !== null + ? sprintf(' Last 13 weeks: %d%% hit rate.', (int) round($trailingHitRate * 100)) + : ''; + + return $headline.' '.$driver.$duty.$accuracy; + } + + private function headline(WeeklyPrediction $prediction): string + { + $absP = round(abs($prediction->magnitudePence) / 100, 1); + + return match ($prediction->direction) { + 'rising' => sprintf('Model expects pump prices to rise by ~%sp/L next week.', number_format($absP, 1)), + 'falling' => sprintf('Model expects pump prices to fall by ~%sp/L next week.', number_format($absP, 1)), + default => 'Pump prices are likely flat next week.', + }; + } + + /** @param array $contributions */ + private function dominantFeatureSentence(array $contributions): string + { + if ($contributions === []) { + return 'Drawn from the full feature set with no single dominant signal.'; + } + + uasort($contributions, fn (float $a, float $b): int => abs($b) <=> abs($a)); + $topName = array_key_first($contributions); + $phrase = self::PHRASES[$topName] ?? $topName; + + return sprintf('Driver: %s.', $phrase); + } +} diff --git a/app/Services/Forecasting/UkBankHolidays.php b/app/Services/Forecasting/UkBankHolidays.php new file mode 100644 index 0000000..04a75ca --- /dev/null +++ b/app/Services/Forecasting/UkBankHolidays.php @@ -0,0 +1,146 @@ + + */ + public static function forYear(int $year): array + { + $dates = []; + + // Easter-anchored + [$em, $ed] = self::easter($year); + $easter = Carbon::create($year, $em, $ed); + $dates[] = $easter->copy()->subDays(2); // Good Friday + $dates[] = $easter->copy()->addDay(); // Easter Monday + + // Floating Mondays + $dates[] = self::firstMondayOf($year, 5); + $dates[] = self::lastMondayOf($year, 5); + $dates[] = self::lastMondayOf($year, 8); + + // Fixed dates with substitution + $dates[] = self::substituteForward(Carbon::create($year, 1, 1), $dates); + $christmas = self::substituteForward(Carbon::create($year, 12, 25), $dates); + $dates[] = $christmas; + $boxing = self::substituteForward(Carbon::create($year, 12, 26), $dates); + $dates[] = $boxing; + + usort($dates, fn (CarbonInterface $a, CarbonInterface $b): int => $a->getTimestamp() <=> $b->getTimestamp()); + + return $dates; + } + + /** + * Is there a UK bank holiday in [$from, $from + $daysAhead - 1]? + */ + public static function holidayWithin(CarbonInterface $from, int $daysAhead): bool + { + $end = $from->copy()->addDays($daysAhead - 1); + $years = array_unique([(int) $from->format('Y'), (int) $end->format('Y')]); + + foreach ($years as $year) { + foreach (self::forYear($year) as $holiday) { + if ($holiday->betweenIncluded($from, $end)) { + return true; + } + } + } + + return false; + } + + /** + * Anonymous Gregorian algorithm for Easter Sunday. + * + * @return array{0: int, 1: int} [month, day] + */ + private static function easter(int $year): array + { + $a = $year % 19; + $b = intdiv($year, 100); + $c = $year % 100; + $d = intdiv($b, 4); + $e = $b % 4; + $f = intdiv($b + 8, 25); + $g = intdiv($b - $f + 1, 3); + $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); + $k = $c % 4; + $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; + $m = intdiv($a + 11 * $h + 22 * $l, 451); + $month = intdiv($h + $l - 7 * $m + 114, 31); + $day = (($h + $l - 7 * $m + 114) % 31) + 1; + + return [$month, $day]; + } + + private static function firstMondayOf(int $year, int $month): Carbon + { + $d = Carbon::create($year, $month, 1); + while ($d->dayOfWeek !== Carbon::MONDAY) { + $d->addDay(); + } + + return $d; + } + + private static function lastMondayOf(int $year, int $month): Carbon + { + $d = Carbon::create($year, $month, 1)->endOfMonth()->startOfDay(); + while ($d->dayOfWeek !== Carbon::MONDAY) { + $d->subDay(); + } + + return $d; + } + + /** + * If $candidate falls on a weekend or collides with an already-claimed + * date, return the next non-weekend non-claimed date. Christmas/Boxing + * cascade is handled because we pass in the running list. + * + * @param array $taken + */ + private static function substituteForward(Carbon $candidate, array $taken): Carbon + { + $d = $candidate->copy(); + while (true) { + $isWeekend = in_array($d->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY], true); + $isTaken = false; + foreach ($taken as $t) { + if ($t->isSameDay($d)) { + $isTaken = true; + break; + } + } + if (! $isWeekend && ! $isTaken) { + return $d; + } + $d->addDay(); + } + } +} diff --git a/app/Services/Forecasting/VolatilityRegimeService.php b/app/Services/Forecasting/VolatilityRegimeService.php new file mode 100644 index 0000000..b3d8d99 --- /dev/null +++ b/app/Services/Forecasting/VolatilityRegimeService.php @@ -0,0 +1,209 @@ + 3% (FRED `DCOILBRENTEU`). + * 2. Most recent `llm_overlays.major_impact_event = true` AND at + * least one verified URL. + * 3. `station_prices` daily churn > 1.5× 30-day baseline. Gated + * until ≥ 180 days of polling — toggleable via config. + * 4. `watched_events` row covering today. + * + * When the flag flips ON, an event-driven LLM refresh is queued + * (Layer 4 enforces its own 4h cooldown). When OFF, the row is + * closed with `flipped_off_at`. + */ +final class VolatilityRegimeService +{ + private const float BRENT_MOVE_PCT = 3.0; + + private const float STATION_CHURN_RATIO = 1.5; + + private const int STATION_CHURN_MIN_POLLING_DAYS = 180; + + public function __construct( + private readonly LlmOverlayService $llmOverlay, + ) {} + + public function evaluate(): ?VolatilityRegime + { + $trigger = $this->detectTrigger(); + $current = VolatilityRegime::currentlyActive(); + + if ($trigger !== null && $current === null) { + $row = $this->flipOn($trigger); + $this->llmOverlay->run(eventDriven: true); + + return $row; + } + + if ($trigger === null && $current !== null) { + $this->flipOff($current); + + return null; + } + + return $current; + } + + /** @return array{type: string, detail: string}|null */ + private function detectTrigger(): ?array + { + return $this->brentMoveTrigger() + ?? $this->llmEventTrigger() + ?? $this->stationChurnTrigger() + ?? $this->watchedEventTrigger(); + } + + /** @return array{type: string, detail: string}|null */ + private function brentMoveTrigger(): ?array + { + $rows = BrentPrice::query() + ->orderByDesc('date') + ->limit(2) + ->get(['date', 'price_usd']); + + if ($rows->count() < 2) { + return null; + } + + $latest = (float) $rows[0]->price_usd; + $prior = (float) $rows[1]->price_usd; + if ($prior === 0.0) { + return null; + } + + $pctMove = abs(($latest - $prior) / $prior) * 100; + if ($pctMove <= self::BRENT_MOVE_PCT) { + return null; + } + + $direction = $latest > $prior ? '+' : '-'; + + return [ + 'type' => 'brent_move', + 'detail' => sprintf('Brent %s%.2f%% (%s → %s)', $direction, $pctMove, $rows[1]->date->toDateString(), $rows[0]->date->toDateString()), + ]; + } + + /** @return array{type: string, detail: string}|null */ + private function llmEventTrigger(): ?array + { + $latest = LlmOverlay::query()->orderByDesc('ran_at')->first(); + + if ($latest === null || ! $latest->major_impact_event) { + return null; + } + + $hasVerifiedUrl = collect((array) $latest->events_json) + ->contains(fn ($e): bool => is_array($e) && ! empty($e['url'])); + + if (! $hasVerifiedUrl) { + return null; + } + + $headline = collect((array) $latest->events_json)->pluck('headline')->filter()->first(); + + return [ + 'type' => 'llm_event', + 'detail' => sprintf('LLM major impact: %s', $headline ?? 'unspecified'), + ]; + } + + /** @return array{type: string, detail: string}|null */ + private function stationChurnTrigger(): ?array + { + if (! $this->stationChurnEnabled()) { + return null; + } + + $oldest = DB::table('station_prices')->min('price_effective_at'); + if ($oldest === null) { + return null; + } + + $pollingDays = (int) abs(now()->diffInDays($oldest)); + if ($pollingDays < self::STATION_CHURN_MIN_POLLING_DAYS) { + return null; + } + + $last24h = (int) DB::table('station_prices') + ->where('price_effective_at', '>=', now()->subDay()) + ->distinct('station_id') + ->count('station_id'); + + $baseline = (int) DB::table('station_prices') + ->where('price_effective_at', '>=', now()->subDays(30)) + ->where('price_effective_at', '<', now()->subDay()) + ->distinct('station_id') + ->count('station_id'); + + if ($baseline === 0) { + return null; + } + + $dailyBaseline = $baseline / 29; // 29 days of history before yesterday + if ($last24h <= $dailyBaseline * self::STATION_CHURN_RATIO) { + return null; + } + + return [ + 'type' => 'station_churn', + 'detail' => sprintf('Station churn %d/24h vs %.1f baseline (%.2fx)', $last24h, $dailyBaseline, $last24h / $dailyBaseline), + ]; + } + + /** @return array{type: string, detail: string}|null */ + private function watchedEventTrigger(): ?array + { + $row = WatchedEvent::query() + ->where('starts_at', '<=', now()) + ->where('ends_at', '>=', now()) + ->orderBy('starts_at') + ->first(); + + if ($row === null) { + return null; + } + + return [ + 'type' => 'manual', + 'detail' => sprintf('Watched event: %s', $row->label), + ]; + } + + private function stationChurnEnabled(): bool + { + return (bool) config('services.forecasting.station_churn_enabled', false); + } + + /** @param array{type: string, detail: string} $trigger */ + private function flipOn(array $trigger): VolatilityRegime + { + return VolatilityRegime::query()->create([ + 'flipped_on_at' => now(), + 'flipped_off_at' => null, + 'trigger' => $trigger['type'], + 'trigger_detail' => $trigger['detail'], + 'active' => true, + ]); + } + + private function flipOff(VolatilityRegime $row): void + { + $row->update([ + 'flipped_off_at' => now(), + 'active' => false, + ]); + } +} diff --git a/app/Services/Forecasting/WeeklyForecastService.php b/app/Services/Forecasting/WeeklyForecastService.php new file mode 100644 index 0000000..39453b3 --- /dev/null +++ b/app/Services/Forecasting/WeeklyForecastService.php @@ -0,0 +1,307 @@ +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'], + ); + } +} diff --git a/app/Services/Forecasting/WeeklyPrediction.php b/app/Services/Forecasting/WeeklyPrediction.php new file mode 100644 index 0000000..a6bc288 --- /dev/null +++ b/app/Services/Forecasting/WeeklyPrediction.php @@ -0,0 +1,20 @@ +|null */ + private ?array $byDate = null; + + public function ulspPence(string $date): ?int + { + $row = $this->byDate()[$date] ?? null; + + return $row === null ? null : (int) $row->ulsp_pence; + } + + public function ulsdPence(string $date): ?int + { + $row = $this->byDate()[$date] ?? null; + + return $row === null ? null : (int) $row->ulsd_pence; + } + + /** @return array Sorted ascending. */ + public function allDates(): array + { + return array_keys($this->byDate()); + } + + /** @return array */ + private function byDate(): array + { + if ($this->byDate !== null) { + return $this->byDate; + } + + $rows = DB::table('weekly_pump_prices') + ->orderBy('date') + ->get(['date', 'ulsp_pence', 'ulsd_pence']); + + $map = []; + foreach ($rows as $r) { + $map[(string) $r->date] = $r; + } + + $this->byDate = $map; + + return $map; + } +} diff --git a/app/Services/StationSearch/StationSearchService.php b/app/Services/StationSearch/StationSearchService.php index 42c38d7..ed57dfc 100644 --- a/app/Services/StationSearch/StationSearchService.php +++ b/app/Services/StationSearch/StationSearchService.php @@ -7,8 +7,9 @@ use App\Enums\PriceReliability; use App\Models\Search; use App\Models\Station; use App\Models\User; +use App\Services\Forecasting\LocalSnapshotService; +use App\Services\Forecasting\WeeklyForecastService; use App\Services\HaversineQuery; -use App\Services\NationalFuelPredictionService; use App\Services\PlanFeatures; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Carbon; @@ -17,7 +18,8 @@ use Illuminate\Support\Collection; final class StationSearchService { public function __construct( - private readonly NationalFuelPredictionService $predictionService, + private readonly WeeklyForecastService $weeklyForecast, + private readonly LocalSnapshotService $localSnapshot, ) {} public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult @@ -134,7 +136,10 @@ final class StationSearchService */ private function buildPrediction(?User $user, SearchCriteria $criteria): array { - $result = $this->predictionService->predict($criteria->lat, $criteria->lng); + $result = $this->weeklyForecast->currentForecast(); + // Layer 1 is national; the region_key only reflects whether the + // caller passed coordinates so the JSON contract stays stable. + $result['region_key'] = 'regional'; $canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions'); @@ -146,6 +151,13 @@ final class StationSearchService ]; } + $result['local_snapshot'] = $this->localSnapshot->snapshot( + fuelType: $criteria->fuelType->value, + lat: $criteria->lat, + lng: $criteria->lng, + radiusKm: max(10, (int) $criteria->radiusKm), + ); + return $result; } } diff --git a/config/services.php b/config/services.php index e736842..e073fd2 100644 --- a/config/services.php +++ b/config/services.php @@ -68,6 +68,13 @@ return [ 'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'), ], + 'forecasting' => [ + // Phase 9 station-churn trigger is gated until ≥180 days of stable + // polling. Flip on once `station_prices` has continuous coverage — + // see `.claude/rules/forecasting.md`. + 'station_churn_enabled' => env('FORECASTING_STATION_CHURN_ENABLED', false), + ], + 'fuelalert' => [ 'api_key' => env('FUELALERT_API_KEY'), ], diff --git a/database/factories/BacktestFactory.php b/database/factories/BacktestFactory.php new file mode 100644 index 0000000..8a1f4c5 --- /dev/null +++ b/database/factories/BacktestFactory.php @@ -0,0 +1,28 @@ + */ +class BacktestFactory extends Factory +{ + public function definition(): array + { + return [ + 'model_version' => 'test-'.fake()->unique()->bothify('????????'), + 'features_json' => ['features' => ['delta_ulsp_lag_0']], + 'coefficients_json' => null, + 'train_start' => '2018-01-01', + 'train_end' => '2024-01-01', + 'eval_start' => '2024-01-08', + 'eval_end' => '2026-04-27', + 'directional_accuracy' => fake()->randomFloat(2, 50, 75), + 'mae_pence' => fake()->randomFloat(2, 0.4, 1.0), + 'calibration_table' => ['0.0-0.5' => 0.55, '0.5-1.0' => 0.65, '1.0+' => 0.72], + 'leak_suspected' => false, + 'ran_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_05_01_122839_create_backtests_table.php b/database/migrations/2026_05_01_122839_create_backtests_table.php new file mode 100644 index 0000000..dc8f222 --- /dev/null +++ b/database/migrations/2026_05_01_122839_create_backtests_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('model_version', 64)->unique()->comment('Deterministic hash of FeatureSpec'); + $table->json('features_json')->comment('Serialised feature spec used for this run'); + $table->json('coefficients_json')->nullable()->comment('Trained coefficients, null for non-parametric models like NaiveBaseline'); + $table->date('train_start'); + $table->date('train_end'); + $table->date('eval_start'); + $table->date('eval_end'); + $table->decimal('directional_accuracy', 5, 2)->nullable()->comment('% of eval weeks where direction class was correct'); + $table->decimal('mae_pence', 5, 2)->nullable()->comment('Mean absolute error in pence × 100'); + $table->json('calibration_table')->nullable()->comment('{bin_low..bin_high → empirical_hit_rate}'); + $table->boolean('leak_suspected')->default(false)->comment('Secondary smell test: directional_accuracy > 75'); + $table->dateTime('ran_at'); + $table->timestamps(); + + $table->index(['ran_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backtests'); + } +}; diff --git a/database/migrations/2026_05_01_125121_create_forecast_outcomes_table.php b/database/migrations/2026_05_01_125121_create_forecast_outcomes_table.php new file mode 100644 index 0000000..98280d4 --- /dev/null +++ b/database/migrations/2026_05_01_125121_create_forecast_outcomes_table.php @@ -0,0 +1,35 @@ +date('forecast_for'); + $table->string('model_version', 64); + $table->enum('predicted_class', ['rising', 'falling', 'flat']); + $table->enum('actual_class', ['rising', 'falling', 'flat']); + $table->boolean('correct'); + $table->unsignedSmallInteger('abs_error_pence')->comment('|predicted − actual|, in pence × 100'); + $table->dateTime('resolved_at'); + + $table->primary(['forecast_for', 'model_version']); + $table->index(['model_version', 'resolved_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('forecast_outcomes'); + } +}; diff --git a/database/migrations/2026_05_01_125121_create_weekly_forecasts_table.php b/database/migrations/2026_05_01_125121_create_weekly_forecasts_table.php new file mode 100644 index 0000000..19fca20 --- /dev/null +++ b/database/migrations/2026_05_01_125121_create_weekly_forecasts_table.php @@ -0,0 +1,38 @@ +id(); + $table->date('forecast_for')->comment('Monday the forecast covers'); + $table->string('model_version', 64); + $table->enum('direction', ['rising', 'falling', 'flat']); + $table->smallInteger('magnitude_pence')->comment('Predicted Δ × 100, signed'); + $table->unsignedTinyInteger('ridge_confidence')->comment('0..100 calibrated from backtest residuals'); + $table->boolean('flagged_duty_change')->default(false)->comment('±4 weeks of a known duty change'); + $table->text('reasoning')->comment('Generated from features actually used'); + $table->dateTime('generated_at'); + $table->timestamps(); + + $table->unique(['forecast_for', 'model_version']); + $table->index(['forecast_for', 'generated_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('weekly_forecasts'); + } +}; diff --git a/database/migrations/2026_05_01_130448_create_llm_overlays_table.php b/database/migrations/2026_05_01_130448_create_llm_overlays_table.php new file mode 100644 index 0000000..c467113 --- /dev/null +++ b/database/migrations/2026_05_01_130448_create_llm_overlays_table.php @@ -0,0 +1,39 @@ +id(); + $table->dateTime('ran_at'); + $table->date('forecast_for_week')->comment('The Monday this overlay annotates'); + $table->enum('direction', ['rising', 'falling', 'flat']); + $table->unsignedTinyInteger('confidence')->comment('0..75 (cap enforced in code — web-searched LLMs are systematically overconfident)'); + $table->text('reasoning'); + $table->json('events_json')->comment('Cited events with verified URLs'); + $table->boolean('agrees_with_ridge'); + $table->boolean('major_impact_event')->default(false); + $table->boolean('volatility_flag_on')->default(false)->comment('Whether the volatility regime flag was active when this row was written'); + $table->boolean('search_used')->default(true); + $table->timestamps(); + + $table->index(['forecast_for_week', 'ran_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('llm_overlays'); + } +}; diff --git a/database/migrations/2026_05_01_130519_create_volatility_regimes_table.php b/database/migrations/2026_05_01_130519_create_volatility_regimes_table.php new file mode 100644 index 0000000..b9721ae --- /dev/null +++ b/database/migrations/2026_05_01_130519_create_volatility_regimes_table.php @@ -0,0 +1,34 @@ +id(); + $table->dateTime('flipped_on_at'); + $table->dateTime('flipped_off_at')->nullable(); + $table->enum('trigger', ['brent_move', 'llm_event', 'station_churn', 'manual']); + $table->text('trigger_detail')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + + $table->index(['active', 'flipped_on_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('volatility_regimes'); + } +}; diff --git a/database/migrations/2026_05_01_130519_create_watched_events_table.php b/database/migrations/2026_05_01_130519_create_watched_events_table.php new file mode 100644 index 0000000..2e1ccfe --- /dev/null +++ b/database/migrations/2026_05_01_130519_create_watched_events_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('label', 128); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['starts_at', 'ends_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('watched_events'); + } +}; diff --git a/routes/console.php b/routes/console.php index b637299..8354a0e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -26,13 +26,49 @@ Schedule::command('fuel:poll --full') ->onOneServer() ->runInBackground(); -// Fetch FRED prices and generate oil price prediction daily at 7am -Schedule::command('oil:predict --fetch') +// Phase 7: Brent crude refresh at 06:30 UK so the 07:00 LLM overlay has +// fresh context. +Schedule::command('oil:fetch') + ->dailyAt('06:30') + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + +// Phase 8: news-aware overlay on the calibrated ridge forecast. +Schedule::command('forecast:llm-overlay') ->dailyAt('07:00') ->withoutOverlapping() ->onOneServer() ->runInBackground(); +// Pull the latest BEIS Weekly Road Fuel Prices CSV from gov.uk every +// Monday at 09:30 UK. The publication usually lands earlier in the +// morning, so 09:30 is a safe buffer. Re-running on the same week is +// idempotent (upsert keyed on `date`). +Schedule::command('beis:import') + ->mondays() + ->at('09:30') + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + +// Phase 6: pair past forecasts with actual outcomes after BEIS +// publishes. Runs after `beis:import` so the new ULSP row is in DB. +Schedule::command('forecast:resolve-outcomes') + ->mondays() + ->at('10:00') + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + +// Phase 9: hourly volatility regime check (Brent moves, LLM events, +// station churn (gated), watched events). +Schedule::command('forecast:evaluate-volatility') + ->hourly() + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + // Move station_prices rows older than 12 months into station_prices_archive // once a month. Keeps the partitioned hot table bounded. Schedule::command('fuel:archive') diff --git a/tests/Unit/Services/BrentPriceFetcherTest.php b/tests/Unit/Services/BrentPriceFetcherTest.php index fe720a5..13a4db3 100644 --- a/tests/Unit/Services/BrentPriceFetcherTest.php +++ b/tests/Unit/Services/BrentPriceFetcherTest.php @@ -20,6 +20,37 @@ beforeEach(function (): void { ); }); +it('backfills a date range from FRED into brent_prices', function (): void { + Http::fake([ + '*api.stlouisfed.org/*' => Http::response([ + 'observations' => [ + ['date' => '2018-01-02', 'value' => '66.65'], + ['date' => '2018-01-03', 'value' => '67.84'], + ['date' => '2018-01-04', 'value' => '67.49'], + ['date' => '2018-01-05', 'value' => '67.72'], + ], + ]), + ]); + + $count = $this->fetcher->backfillFromFred('2018-01-01', '2018-01-07'); + + expect($count)->toBe(4) + ->and(BrentPrice::count())->toBe(4) + ->and(BrentPrice::find('2018-01-02')->price_usd)->toBe('66.65'); +}); + +it('throws when FRED backfill returns no usable rows', function (): void { + Http::fake([ + '*api.stlouisfed.org/*' => Http::response([ + 'observations' => [ + ['date' => '2018-01-01', 'value' => '.'], // FRED placeholder + ], + ]), + ]); + + $this->fetcher->backfillFromFred('2018-01-01', '2018-01-01'); +})->throws(BrentPriceFetchException::class); + it('fetches and stores brent prices from EIA', function (): void { Http::fake([ '*eia.gov/*' => Http::response([ diff --git a/tests/Unit/Services/Forecasting/BacktestRunnerTest.php b/tests/Unit/Services/Forecasting/BacktestRunnerTest.php new file mode 100644 index 0000000..ce0e94e --- /dev/null +++ b/tests/Unit/Services/Forecasting/BacktestRunnerTest.php @@ -0,0 +1,222 @@ +featureName; + } + + public function valueFor(CarbonInterface $targetMonday): float + { + return 0.0; + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + return [$targetMonday->copy()->addDays($this->offsetDays)]; + } + }; +} + +/** + * Stub model: predicts a fixed magnitude every week. Lets us craft + * specific accuracy / MAE outcomes for assertions. + */ +function stubModel(float $alwaysPredictPence, string $modelLabel = 'stub'): WeeklyForecastModel +{ + return new class($alwaysPredictPence, $modelLabel) implements WeeklyForecastModel + { + public function __construct( + private readonly float $alwaysPredictPence, + private readonly string $modelLabel, + ) {} + + public function featureSpec(): FeatureSpec + { + return new FeatureSpec( + modelLabel: $this->modelLabel, + features: [backtestFeature('lag_1w')], + ); + } + + public function train(array $trainingMondays): void {} + + public function predict(CarbonInterface $targetMonday): WeeklyPrediction + { + return new WeeklyPrediction( + targetMonday: $targetMonday, + magnitudePence: $this->alwaysPredictPence, + direction: match (true) { + $this->alwaysPredictPence > 0.2 => 'rising', + $this->alwaysPredictPence < -0.2 => 'falling', + default => 'flat', + }, + ); + } + + public function coefficients(): ?array + { + return null; + } + }; +} + +function seedWeeklyPumpPrices(): void +{ + // 8 weeks of synthetic prices, gently rising + $start = Carbon::parse('2024-01-01'); + for ($i = 0; $i < 8; $i++) { + DB::table('weekly_pump_prices')->insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => 14000 + ($i * 100), // each week +1p + 'ulsd_pence' => 15000 + ($i * 80), + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } +} + +it('refuses to run when the spec has structural leakage', function () { + seedWeeklyPumpPrices(); + + $leaky = new class implements WeeklyForecastModel + { + public function featureSpec(): FeatureSpec + { + return new FeatureSpec( + modelLabel: 'leaky', + features: [backtestFeature('reads_target_week', 0)], + ); + } + + public function train(array $trainingMondays): void {} + + public function predict(CarbonInterface $targetMonday): WeeklyPrediction + { + return new WeeklyPrediction($targetMonday, 0.0, 'flat'); + } + + public function coefficients(): ?array + { + return null; + } + }; + + (new BacktestRunner)->run( + $leaky, + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); +})->throws(LeakDetectorException::class); + +it('persists a backtest row with metrics for a clean run', function () { + seedWeeklyPumpPrices(); + + $result = (new BacktestRunner)->run( + stubModel(alwaysPredictPence: 100.0), // always predicts +1p + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect($result)->toBeInstanceOf(Backtest::class); + expect(Backtest::query()->count())->toBe(1); + + $row = Backtest::query()->first(); + expect($row->model_version)->toStartWith('stub-') + ->and($row->train_start->toDateString())->toBe('2024-01-01') + ->and($row->eval_end->toDateString())->toBe('2024-02-19') + ->and($row->ran_at)->not->toBeNull(); +}); + +it('computes 100% directional accuracy when stub always nails the direction', function () { + seedWeeklyPumpPrices(); + + // Series rises by 1p every week, so direction is always 'rising'. + // Stub always predicts +1p (rising) → direction should always match. + $result = (new BacktestRunner)->run( + stubModel(alwaysPredictPence: 100.0), + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect((float) $result->directional_accuracy)->toBe(100.0); +}); + +it('computes 0% directional accuracy when stub always picks the wrong direction', function () { + seedWeeklyPumpPrices(); + + // Series rises every week, but stub predicts -1p (falling) → 0% accuracy. + $result = (new BacktestRunner)->run( + stubModel(alwaysPredictPence: -100.0), + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect((float) $result->directional_accuracy)->toBe(0.0); +}); + +it('flags leak_suspected when directional accuracy exceeds 75%', function () { + seedWeeklyPumpPrices(); + + $result = (new BacktestRunner)->run( + stubModel(alwaysPredictPence: 100.0), // always right → 100% + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect($result->leak_suspected)->toBeTrue(); +}); + +it('does not flag leak_suspected for realistic accuracy', function () { + seedWeeklyPumpPrices(); + + // Use same direction as data so we get reasonable but not suspicious accuracy. + // Stub flat → wrong every week (data is rising) → 0%, well below 75. + $result = (new BacktestRunner)->run( + stubModel(alwaysPredictPence: 0.0), + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect($result->leak_suspected)->toBeFalse(); +}); diff --git a/tests/Unit/Services/Forecasting/BeisImporterTest.php b/tests/Unit/Services/Forecasting/BeisImporterTest.php new file mode 100644 index 0000000..cbaf88d --- /dev/null +++ b/tests/Unit/Services/Forecasting/BeisImporterTest.php @@ -0,0 +1,119 @@ + Http::response([ + 'details' => [ + 'attachments' => [ + ['title' => 'Weekly road fuel prices (Excel)', 'url' => 'https://assets.publishing.service.gov.uk/media/x/excel.xlsx'], + ['title' => 'Weekly road fuel prices (CSV) 2018 to 2026', 'url' => $cdnUrl], + ['title' => 'Weekly road fuel prices (CSV) 2003 to 2017', 'url' => 'https://assets.publishing.service.gov.uk/media/y/old.csv'], + ], + ], + ]), + $cdnUrl => Http::response($body, 200, ['Content-Type' => 'text/csv']), + ]); +} + +it('resolves the CSV URL from the gov.uk content API and upserts rows', function (): void { + $csv = "Date,ULSP,ULSD,ULSP duty,ULSD duty,ULSP VAT,ULSD VAT\r\n" + ."20/04/2026,157.62,191.24,52.95,52.95,20,20\r\n" + ."27/04/2026,156.99,189.81,52.95,52.95,20,20\r\n"; + + fakeBeisCsv($csv); + + $result = (new BeisImporter)->import(); + + expect($result['parsed'])->toBe(2) + ->and($result['latest_date'])->toBe('2026-04-27') + ->and(DB::table('weekly_pump_prices')->count())->toBe(2); + + $row = DB::table('weekly_pump_prices')->where('date', '2026-04-27')->first(); + expect((int) $row->ulsp_pence)->toBe(15699) + ->and((int) $row->ulsd_pence)->toBe(18981); +}); + +it('is idempotent on re-run with no new rows', function (): void { + $csv = "Date,ULSP,ULSD,ULSP duty,ULSD duty,ULSP VAT,ULSD VAT\r\n" + ."27/04/2026,156.99,189.81,52.95,52.95,20,20\r\n"; + fakeBeisCsv($csv); + + (new BeisImporter)->import(); + (new BeisImporter)->import(); + + expect(DB::table('weekly_pump_prices')->count())->toBe(1); +}); + +it('updates existing rows when CSV values change (upsert)', function (): void { + // Seed a stale row directly so we can prove the import overwrites it. + DB::table('weekly_pump_prices')->insert([ + 'date' => '2026-04-27', + 'ulsp_pence' => 15500, + 'ulsd_pence' => 18900, + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + + $csv = "Date,ULSP,ULSD,ULSP duty,ULSD duty,ULSP VAT,ULSD VAT\r\n" + ."27/04/2026,157.05,189.85,52.95,52.95,20,20\r\n"; + fakeBeisCsv($csv); + + (new BeisImporter)->import(); + + $row = DB::table('weekly_pump_prices')->where('date', '2026-04-27')->first(); + expect((int) $row->ulsp_pence)->toBe(15705) // updated from 15500 + ->and((int) $row->ulsd_pence)->toBe(18985); +}); + +it('throws when gov.uk API does not contain the expected CSV attachment', function (): void { + Http::fake([ + 'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices' => Http::response([ + 'details' => ['attachments' => [ + ['title' => 'Some other thing', 'url' => 'https://x'], + ]], + ]), + ]); + + (new BeisImporter)->import(); +})->throws(RuntimeException::class, 'did not return an attachment'); + +it('flushes the forecast cache after a successful import', function (): void { + Cache::put('forecast:current:something', 'stale', 3600); + + $csv = "Date,ULSP,ULSD,ULSP duty,ULSD duty,ULSP VAT,ULSD VAT\r\n" + ."27/04/2026,156.99,189.81,52.95,52.95,20,20\r\n"; + fakeBeisCsv($csv); + + (new BeisImporter)->import(); + + expect(Cache::get('forecast:current:something'))->toBeNull(); +}); + +it('skips malformed rows but imports the rest', function (): void { + $csv = "Date,ULSP,ULSD,ULSP duty,ULSD duty,ULSP VAT,ULSD VAT\r\n" + ."27/04/2026,156.99,189.81,52.95,52.95,20,20\r\n" + ."not-a-date,123,123,52.95,52.95,20,20\r\n" + ."20/04/2026,157.62,191.24,52.95,52.95,20,20\r\n"; + + fakeBeisCsv($csv); + + $result = (new BeisImporter)->import(); + + expect($result['parsed'])->toBe(2) + ->and(DB::table('weekly_pump_prices')->count())->toBe(2); +}); diff --git a/tests/Unit/Services/Forecasting/Features/FeaturesTest.php b/tests/Unit/Services/Forecasting/Features/FeaturesTest.php new file mode 100644 index 0000000..f62a3af --- /dev/null +++ b/tests/Unit/Services/Forecasting/Features/FeaturesTest.php @@ -0,0 +1,146 @@ +insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => $base + ($i * $stepUlsp), + 'ulsd_pence' => 15000 + ($i * $stepUlsd), + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } +} + +it('DeltaUlspLag(0) returns ULSP[t-7d] − ULSP[t-14d]', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new DeltaUlspLag($loader, lag: 0); + + // Target = 2024-02-26 → t-7d = 2024-02-19 (ulsp=14700), t-14d = 2024-02-12 (14600). + $value = $feature->valueFor(Carbon::parse('2024-02-26')); + expect($value)->toBe(100.0); +}); + +it('DeltaUlspLag(3) returns ULSP[t-28d] − ULSP[t-35d]', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new DeltaUlspLag($loader, lag: 3); + + // Target = 2024-03-04 → t-28d = 2024-02-05 (14500), t-35d = 2024-01-29 (14400). + $value = $feature->valueFor(Carbon::parse('2024-03-04')); + expect($value)->toBe(100.0); +}); + +it('DeltaUlspLag returns null when underlying data is missing', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new DeltaUlspLag($loader, lag: 0); + + // Target before any seeded data → both lookups miss. + $value = $feature->valueFor(Carbon::parse('2017-01-01')); + expect($value)->toBeNull(); +}); + +it('DeltaUlspLag source dates are strictly before target', function () { + $loader = new WeeklyPumpPriceLoader; + $feature = new DeltaUlspLag($loader, lag: 0); + + $target = Carbon::parse('2024-06-03'); + $sources = $feature->sourceDates($target); + foreach ($sources as $s) { + expect($s->lessThan($target))->toBeTrue(); + } +}); + +it('DeltaUlsdLag(0) returns ULSD difference for the previous week', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new DeltaUlsdLag($loader, lag: 0); + + // Diesel rises by 80 each week. lag 0 = t-7 minus t-14. + $value = $feature->valueFor(Carbon::parse('2024-02-26')); + expect($value)->toBe(80.0); +}); + +it('UlspMinusMa8 returns the gap between latest and 8-week mean', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new UlspMinusMa8($loader); + + // Target = 2024-03-04. Window = 2024-02-26 (latest) ... 2024-01-08 (oldest). + // Values: 14800, 14700, 14600, 14500, 14400, 14300, 14200, 14100. + // Latest = 14800, mean = 14450. Gap = 350. + $value = $feature->valueFor(Carbon::parse('2024-03-04')); + expect($value)->toBe(350.0); +}); + +it('UlspMinusMa8 returns null when 8-week window is incomplete', function () { + seedRisingTenWeeks(); + $loader = new WeeklyPumpPriceLoader; + $feature = new UlspMinusMa8($loader); + + // Target only has 1 week of history before it. + $value = $feature->valueFor(Carbon::parse('2024-01-08')); + expect($value)->toBeNull(); +}); + +it('UlspMinusMa8 source dates are 8 weeks back, all before target', function () { + $loader = new WeeklyPumpPriceLoader; + $feature = new UlspMinusMa8($loader); + + $target = Carbon::parse('2024-06-03'); + $sources = $feature->sourceDates($target); + expect($sources)->toHaveCount(8); + foreach ($sources as $s) { + expect($s->lessThan($target))->toBeTrue(); + } +}); + +it('WeekOfYearTrig returns sin/cos values bounded by [-1,1]', function () { + $sin = new WeekOfYearTrig('sin'); + $cos = new WeekOfYearTrig('cos'); + + foreach (['2024-01-01', '2024-04-15', '2024-07-29', '2024-12-30'] as $d) { + $sv = $sin->valueFor(Carbon::parse($d)); + $cv = $cos->valueFor(Carbon::parse($d)); + expect($sv)->toBeGreaterThanOrEqual(-1.0)->toBeLessThanOrEqual(1.0) + ->and($cv)->toBeGreaterThanOrEqual(-1.0)->toBeLessThanOrEqual(1.0); + } +}); + +it('WeekOfYearTrig source dates are empty (calendar feature)', function () { + $feature = new WeekOfYearTrig('sin'); + expect($feature->sourceDates(Carbon::parse('2024-06-03')))->toBe([]); +}); + +it('WeekOfYearTrig rejects unknown components', function () { + new WeekOfYearTrig('tan'); +})->throws(InvalidArgumentException::class); + +it('IsPreBankHoliday is 1.0 when a UK bank holiday is in the next 7 days', function () { + $feature = new IsPreBankHoliday; + // 2024-04-01 is Easter Monday → that week itself contains a holiday. + expect($feature->valueFor(Carbon::parse('2024-04-01')))->toBe(1.0); +}); + +it('IsPreBankHoliday is 0.0 in a quiet stretch', function () { + $feature = new IsPreBankHoliday; + expect($feature->valueFor(Carbon::parse('2024-07-15')))->toBe(0.0); +}); diff --git a/tests/Unit/Services/Forecasting/LeakDetectorTest.php b/tests/Unit/Services/Forecasting/LeakDetectorTest.php new file mode 100644 index 0000000..23b2fb1 --- /dev/null +++ b/tests/Unit/Services/Forecasting/LeakDetectorTest.php @@ -0,0 +1,118 @@ + $offsetsInDays */ + public function __construct( + private readonly string $featureName, + private readonly array $offsetsInDays, + ) {} + + public function name(): string + { + return $this->featureName; + } + + public function valueFor(CarbonInterface $targetMonday): float + { + return 0.0; + } + + public function sourceDates(CarbonInterface $targetMonday): array + { + return array_map( + fn (int $offset): CarbonInterface => $targetMonday->copy()->addDays($offset), + $this->offsetsInDays, + ); + } + }; +} + +it('passes when every feature reads strictly before the target Monday', function () { + $spec = new FeatureSpec( + modelLabel: 'test', + features: [ + makeFeature('lag_1w', [-7]), + makeFeature('lag_4w', [-7, -14, -21, -28]), + ], + ); + + $report = (new LeakDetector)->validate($spec, [Carbon::parse('2024-06-03')]); + + expect($report)->toBeInstanceOf(LeakReport::class) + ->and($report->hasLeaks())->toBeFalse() + ->and($report->leaks)->toBe([]); +}); + +it('flags a feature whose source date IS the target Monday', function () { + $spec = new FeatureSpec( + modelLabel: 'test', + features: [makeFeature('same_day', [0])], + ); + + $report = (new LeakDetector)->validate($spec, [Carbon::parse('2024-06-03')]); + + expect($report->hasLeaks())->toBeTrue() + ->and($report->leaks)->toHaveCount(1) + ->and($report->leaks[0]['feature'])->toBe('same_day'); +}); + +it('flags a feature whose source date is AFTER the target Monday', function () { + $spec = new FeatureSpec( + modelLabel: 'test', + features: [makeFeature('future', [7])], + ); + + $report = (new LeakDetector)->validate($spec, [Carbon::parse('2024-06-03')]); + + expect($report->hasLeaks())->toBeTrue() + ->and($report->leaks[0]['feature'])->toBe('future') + ->and($report->leaks[0]['target_monday'])->toBe('2024-06-03') + ->and($report->leaks[0]['source_date'])->toBe('2024-06-10'); +}); + +it('checks every training week, not just the first', function () { + $spec = new FeatureSpec( + modelLabel: 'test', + features: [makeFeature('lag_1w', [-7])], + ); + + $weeks = [ + Carbon::parse('2024-06-03'), + Carbon::parse('2024-06-10'), + Carbon::parse('2024-06-17'), + ]; + + $report = (new LeakDetector)->validate($spec, $weeks); + + expect($report->hasLeaks())->toBeFalse(); +}); + +it('reports multiple leaks across multiple features', function () { + $spec = new FeatureSpec( + modelLabel: 'test', + features: [ + makeFeature('clean', [-7]), + makeFeature('leaky_one', [0]), + makeFeature('leaky_two', [3]), + ], + ); + + $report = (new LeakDetector)->validate($spec, [Carbon::parse('2024-06-03')]); + + expect($report->hasLeaks())->toBeTrue() + ->and($report->leaks)->toHaveCount(2); + + $featureNames = array_column($report->leaks, 'feature'); + expect($featureNames)->toContain('leaky_one', 'leaky_two') + ->and($featureNames)->not->toContain('clean'); +}); diff --git a/tests/Unit/Services/Forecasting/LinearAlgebraTest.php b/tests/Unit/Services/Forecasting/LinearAlgebraTest.php new file mode 100644 index 0000000..9dd1afe --- /dev/null +++ b/tests/Unit/Services/Forecasting/LinearAlgebraTest.php @@ -0,0 +1,86 @@ +toBe([ + [1.0, 4.0], + [2.0, 5.0], + [3.0, 6.0], + ]); +}); + +it('multiplies two compatible matrices', function () { + $a = [[1.0, 2.0], [3.0, 4.0]]; + $b = [[5.0, 6.0], [7.0, 8.0]]; + // Hand-checked: [[19,22],[43,50]] + expect(LinearAlgebra::multiply($a, $b))->toBe([ + [19.0, 22.0], + [43.0, 50.0], + ]); +}); + +it('multiplies a matrix by a vector', function () { + $a = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]; + $v = [1.0, 0.0, -1.0]; + expect(LinearAlgebra::multiplyVector($a, $v))->toBe([-2.0, -2.0]); +}); + +it('builds an identity matrix', function () { + expect(LinearAlgebra::identity(3))->toBe([ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ]); +}); + +it('solves a 2x2 linear system', function () { + // 2x + y = 5 + // x + 3y = 10 → x=1, y=3 + $A = [[2.0, 1.0], [1.0, 3.0]]; + $b = [5.0, 10.0]; + $x = LinearAlgebra::solve($A, $b); + expect($x[0])->toBeGreaterThan(0.999)->toBeLessThan(1.001) + ->and($x[1])->toBeGreaterThan(2.999)->toBeLessThan(3.001); +}); + +it('solves a 3x3 linear system with partial pivoting', function () { + // First pivot is 0 — only succeeds with partial pivoting. + // det(A) = -4 (non-singular). Solution: x=2, y=1, z=3 → b = [5, 6, 13] + $A = [[0.0, 2.0, 1.0], [1.0, 1.0, 1.0], [2.0, 0.0, 3.0]]; + $b = [5.0, 6.0, 13.0]; + $x = LinearAlgebra::solve($A, $b); + expect($x[0])->toEqualWithDelta(2.0, 1e-9) + ->and($x[1])->toEqualWithDelta(1.0, 1e-9) + ->and($x[2])->toEqualWithDelta(3.0, 1e-9); +}); + +it('ridgeSolve recovers a known signal under low lambda', function () { + // y = 3x + noise. Lambda = 0.001 (effectively OLS). + // X is single-feature. Expect coefficient ≈ 3. + $X = [[1.0], [2.0], [3.0], [4.0], [5.0]]; + $y = [3.0, 6.0, 9.0, 12.0, 15.0]; + $beta = LinearAlgebra::ridgeSolve($X, $y, 0.001); + expect($beta[0])->toEqualWithDelta(3.0, 1e-3); +}); + +it('ridgeSolve shrinks coefficients toward zero with high lambda', function () { + $X = [[1.0], [2.0], [3.0], [4.0], [5.0]]; + $y = [3.0, 6.0, 9.0, 12.0, 15.0]; + $betaLow = LinearAlgebra::ridgeSolve($X, $y, 0.001); + $betaHigh = LinearAlgebra::ridgeSolve($X, $y, 1000.0); + expect(abs($betaHigh[0]))->toBeLessThan(abs($betaLow[0])); +}); + +it('rejects multiplication of incompatible matrices', function () { + $a = [[1.0, 2.0]]; // 1x2 + $b = [[1.0], [2.0], [3.0]]; // 3x1 + LinearAlgebra::multiply($a, $b); +})->throws(InvalidArgumentException::class); + +it('throws when solving a singular matrix', function () { + $A = [[1.0, 2.0], [2.0, 4.0]]; // row 2 is 2× row 1 + $b = [3.0, 6.0]; + LinearAlgebra::solve($A, $b); +})->throws(RuntimeException::class); diff --git a/tests/Unit/Services/Forecasting/LlmOverlayServiceTest.php b/tests/Unit/Services/Forecasting/LlmOverlayServiceTest.php new file mode 100644 index 0000000..473a130 --- /dev/null +++ b/tests/Unit/Services/Forecasting/LlmOverlayServiceTest.php @@ -0,0 +1,248 @@ + Http::sequence() + ->push([ + 'stop_reason' => 'end_turn', + 'content' => [['type' => 'text', 'text' => 'Search summary.']], + ]) + ->push([ + 'stop_reason' => 'tool_use', + 'content' => [[ + 'type' => 'tool_use', + 'name' => 'submit_overlay', + 'input' => [ + 'direction' => $direction, + 'confidence' => $confidence, + 'reasoning_short' => 'Test reasoning.', + 'events_cited' => $events, + 'agrees_with_ridge' => true, + 'major_impact_event' => $major, + ], + ]], + ]), + // URL HEAD verification probes — accept everything by default + '*' => Http::response('', 200), + ]); +} + +it('skips when ANTHROPIC_API_KEY is not set', function (): void { + Config::set('services.anthropic.api_key', null); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run())->toBeNull(); +}); + +it('rejects the overlay when no events are cited', function (): void { + fakeAnthropicWithOverlay('rising', 60, []); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run())->toBeNull() + ->and(LlmOverlay::query()->count())->toBe(0); +}); + +it('verifies a URL via GET fallback when HEAD returns 405', function (): void { + Http::fake([ + '*api.anthropic.com/*' => Http::sequence() + ->push([ + 'stop_reason' => 'end_turn', + 'content' => [['type' => 'text', 'text' => 'ok']], + ]) + ->push([ + 'stop_reason' => 'tool_use', + 'content' => [[ + 'type' => 'tool_use', + 'name' => 'submit_overlay', + 'input' => [ + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning_short' => 'Hostile-to-HEAD source.', + 'events_cited' => [ + ['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'], + ], + 'agrees_with_ridge' => true, + 'major_impact_event' => false, + ], + ]], + ]), + 'reuters.com/*' => Http::sequence() + ->push('', 405) // HEAD → 405 Method Not Allowed + ->push('partial-body', 200), // GET fallback succeeds + ]); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + $row = $service->run(); + + expect($row)->not->toBeNull() + ->and($row->events_json)->toHaveCount(1); +}); + +it('rejects the overlay when both HEAD and GET fail', function (): void { + Http::fake([ + '*api.anthropic.com/*' => Http::sequence() + ->push([ + 'stop_reason' => 'end_turn', + 'content' => [['type' => 'text', 'text' => 'ok']], + ]) + ->push([ + 'stop_reason' => 'tool_use', + 'content' => [[ + 'type' => 'tool_use', + 'name' => 'submit_overlay', + 'input' => [ + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning_short' => 'Truly dead URL.', + 'events_cited' => [ + ['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'], + ], + 'agrees_with_ridge' => true, + 'major_impact_event' => false, + ], + ]], + ]), + 'example.com/*' => Http::sequence() + ->push('', 404) // HEAD → 404 + ->push('', 404), // GET → still 404 + ]); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run())->toBeNull() + ->and(LlmOverlay::query()->count())->toBe(0); +}); + +it('rejects the overlay when every cited URL is unreachable', function (): void { + Http::fake([ + '*api.anthropic.com/*' => Http::sequence() + ->push([ + 'stop_reason' => 'end_turn', + 'content' => [['type' => 'text', 'text' => 'ok']], + ]) + ->push([ + 'stop_reason' => 'tool_use', + 'content' => [[ + 'type' => 'tool_use', + 'name' => 'submit_overlay', + 'input' => [ + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning_short' => 'Test.', + 'events_cited' => [ + ['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'], + ], + 'agrees_with_ridge' => true, + 'major_impact_event' => false, + ], + ]], + ]), + 'example.com/*' => Http::response('', 404), + ]); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run())->toBeNull() + ->and(LlmOverlay::query()->count())->toBe(0); +}); + +it('persists an overlay row with verified citations and capped confidence', function (): void { + fakeAnthropicWithOverlay( + direction: 'rising', + confidence: 95, // above cap → expect capped to 75 + events: [ + ['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'], + ], + major: true, + ); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + $row = $service->run(); + + expect($row)->not->toBeNull() + ->and($row->direction)->toBe('rising') + ->and($row->confidence)->toBe(75) // capped + ->and($row->major_impact_event)->toBeTrue() + ->and($row->search_used)->toBeTrue() + ->and($row->events_json)->toHaveCount(1); +}); + +it('honors the 4-hour cooldown for event-driven calls', function (): void { + Carbon::setTestNow('2026-05-01 10:00:00'); + DB::table('llm_overlays')->insert([ + 'ran_at' => Carbon::parse('2026-05-01 08:00:00'), + 'forecast_for_week' => '2026-05-04', + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning' => 'prior', + 'events_json' => json_encode([['headline' => 'x', 'url' => 'https://reuters.com/x']]), + 'agrees_with_ridge' => true, + 'major_impact_event' => false, + 'volatility_flag_on' => false, + 'search_used' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + fakeAnthropicWithOverlay('falling', 40, [ + ['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'], + ]); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run(eventDriven: true))->toBeNull() // <4h since prior + ->and(LlmOverlay::query()->count())->toBe(1); // no new row inserted + + Carbon::setTestNow(); +}); + +it('always runs (ignores cooldown) when not event-driven', function (): void { + Carbon::setTestNow('2026-05-01 10:00:00'); + DB::table('llm_overlays')->insert([ + 'ran_at' => Carbon::parse('2026-05-01 08:00:00'), + 'forecast_for_week' => '2026-05-04', + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning' => 'prior', + 'events_json' => json_encode([['headline' => 'x', 'url' => 'https://reuters.com/x']]), + 'agrees_with_ridge' => true, + 'major_impact_event' => false, + 'volatility_flag_on' => false, + 'search_used' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + fakeAnthropicWithOverlay('falling', 40, [ + ['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'], + ]); + + $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); + + expect($service->run())->not->toBeNull() + ->and(LlmOverlay::query()->count())->toBe(2); + + Carbon::setTestNow(); +}); diff --git a/tests/Unit/Services/Forecasting/LocalSnapshotServiceTest.php b/tests/Unit/Services/Forecasting/LocalSnapshotServiceTest.php new file mode 100644 index 0000000..194251e --- /dev/null +++ b/tests/Unit/Services/Forecasting/LocalSnapshotServiceTest.php @@ -0,0 +1,113 @@ +create([ + 'lat' => $lat, + 'lng' => $lng, + 'is_supermarket' => $supermarket, + 'trading_name' => $name, + 'brand_name' => $brand, + ]); + StationPriceCurrent::factory()->create([ + 'station_id' => $s->node_id, + 'fuel_type' => 'e10', + 'price_pence' => $pence, + ]); + + return $s; +} + +it('returns the national average across all stations regardless of geo', function () { + seedStation(51.5, -0.1, 14000); + seedStation(53.5, -2.2, 15000); + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + expect($snapshot['national_avg_pence'])->toBe(145.0); +}); + +it('returns the local average filtered to within 50km', function () { + seedStation(51.5, -0.1, 14000); // London → near coord + seedStation(53.5, -2.2, 16000); // Manchester → far + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + expect($snapshot['local_avg_pence'])->toBe(140.0) + ->and($snapshot['local_minus_national_pence'])->toBe(-10.0); +}); + +it('returns the cheapest nearby stations sorted by price ascending', function () { + seedStation(51.5010, -0.1415, 14500, name: 'A'); + seedStation(51.5020, -0.1420, 14000, name: 'B'); + seedStation(51.5030, -0.1430, 14250, name: 'C'); + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.14); + + expect($snapshot['cheapest_nearby'])->toHaveCount(3) + ->and($snapshot['cheapest_nearby'][0]['price_pence'])->toBe(14000) + ->and($snapshot['cheapest_nearby'][0]['name'])->toBe('B') + ->and($snapshot['cheapest_nearby'][2]['price_pence'])->toBe(14500); +}); + +it('caps cheapest_nearby at 5 even when more match', function () { + for ($i = 0; $i < 8; $i++) { + seedStation(51.5 + $i * 0.001, -0.1, 14000 + $i * 50); + } + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + expect($snapshot['cheapest_nearby'])->toHaveCount(5); +}); + +it('computes the supermarket / major split and the gap', function () { + seedStation(51.5, -0.1, 14000, supermarket: true, name: 'Asda'); + seedStation(51.501, -0.101, 14200, supermarket: true, name: 'Tesco'); + seedStation(51.502, -0.102, 14600, supermarket: false, name: 'Shell'); + seedStation(51.503, -0.103, 14800, supermarket: false, name: 'BP'); + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + // Supermarket avg = 141, major avg = 147, gap = -6.0 + expect($snapshot['supermarket_avg_pence'])->toBe(141.0) + ->and($snapshot['major_avg_pence'])->toBe(147.0) + ->and($snapshot['supermarket_gap_pence'])->toBe(-6.0); +}); + +it('returns null gap when one side is empty', function () { + seedStation(51.5, -0.1, 14000, supermarket: true); + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + expect($snapshot['supermarket_avg_pence'])->toBe(140.0) + ->and($snapshot['major_avg_pence'])->toBeNull() + ->and($snapshot['supermarket_gap_pence'])->toBeNull(); +}); + +it('counts stations within radius', function () { + seedStation(51.5, -0.1, 14000); + seedStation(51.501, -0.101, 14200); + seedStation(53.5, -2.2, 14400); // far away + + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1, 25); + + expect($snapshot['stations_within_radius'])->toBe(2); +}); + +it('returns null prices when there is no data at all', function () { + $snapshot = (new LocalSnapshotService)->snapshot('e10', 51.5, -0.1); + + expect($snapshot['national_avg_pence'])->toBeNull() + ->and($snapshot['local_avg_pence'])->toBeNull() + ->and($snapshot['supermarket_avg_pence'])->toBeNull() + ->and($snapshot['major_avg_pence'])->toBeNull() + ->and($snapshot['cheapest_nearby'])->toBe([]) + ->and($snapshot['stations_within_radius'])->toBe(0); +}); diff --git a/tests/Unit/Services/Forecasting/Models/NaiveZeroChangeModelTest.php b/tests/Unit/Services/Forecasting/Models/NaiveZeroChangeModelTest.php new file mode 100644 index 0000000..9b2b374 --- /dev/null +++ b/tests/Unit/Services/Forecasting/Models/NaiveZeroChangeModelTest.php @@ -0,0 +1,56 @@ +predict(Carbon::parse('2024-06-03')); + + expect($prediction->magnitudePence)->toBe(0.0) + ->and($prediction->direction)->toBe('flat'); +}); + +it('has an empty FeatureSpec (no features by design)', function () { + $model = new NaiveZeroChangeModel; + + $spec = $model->featureSpec(); + + expect($spec->modelLabel)->toBe('naive-zero') + ->and($spec->features)->toBe([]) + ->and($spec->modelVersion())->toStartWith('naive-zero-'); +}); + +it('runs cleanly through the backtest harness on real-shape data', function () { + // 8 weeks gently rising — naive predicts flat → expect 0% accuracy. + $start = Carbon::parse('2024-01-01'); + for ($i = 0; $i < 8; $i++) { + DB::table('weekly_pump_prices')->insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => 14000 + ($i * 100), + 'ulsd_pence' => 15000 + ($i * 80), + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } + + $result = (new BacktestRunner)->run( + new NaiveZeroChangeModel, + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-01-29'), + evalStart: Carbon::parse('2024-02-05'), + evalEnd: Carbon::parse('2024-02-19'), + ); + + expect((float) $result->directional_accuracy)->toBe(0.0) + ->and((float) $result->mae_pence)->toBe(1.0) + ->and($result->leak_suspected)->toBeFalse(); +}); diff --git a/tests/Unit/Services/Forecasting/Models/RidgeRegressionModelTest.php b/tests/Unit/Services/Forecasting/Models/RidgeRegressionModelTest.php new file mode 100644 index 0000000..d06e44d --- /dev/null +++ b/tests/Unit/Services/Forecasting/Models/RidgeRegressionModelTest.php @@ -0,0 +1,152 @@ +insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => $price, + 'ulsd_pence' => $price + 800, + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } +} + +it('train + predict produces a non-zero, finite magnitude', function () { + seedRidgeFixture(30); + $loader = new WeeklyPumpPriceLoader; + $model = new RidgeRegressionModel( + spec: new FeatureSpec('ridge-test', [ + new DeltaUlspLag($loader, lag: 0), + new DeltaUlspLag($loader, lag: 1), + new UlspMinusMa8($loader), + ]), + loader: $loader, + lambda: 1.0, + ); + + $training = collect(range(0, 20))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); + $model->train($training); + + $prediction = $model->predict(Carbon::parse('2024-06-03')); + expect(is_finite($prediction->magnitudePence))->toBeTrue() + ->and($prediction->direction)->toBeIn(['rising', 'falling', 'flat']); +}); + +it('coefficients() returns a structured payload after training', function () { + seedRidgeFixture(30); + $loader = new WeeklyPumpPriceLoader; + $features = [ + new DeltaUlspLag($loader, lag: 0), + new DeltaUlspLag($loader, lag: 1), + ]; + $model = new RidgeRegressionModel( + spec: new FeatureSpec('ridge-test', $features), + loader: $loader, + lambda: 1.0, + ); + + $training = collect(range(0, 20))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); + $model->train($training); + + $c = $model->coefficients(); + expect($c)->toHaveKey('intercept') + ->and($c)->toHaveKey('lambda') + ->and($c['lambda'])->toBe(1.0) + ->and($c['features'])->toHaveKey('delta_ulsp_lag_0') + ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('beta_standardised') + ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('mean') + ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('std_dev'); +}); + +it('throws when predict is called before train', function () { + $loader = new WeeklyPumpPriceLoader; + $model = new RidgeRegressionModel( + spec: new FeatureSpec('ridge-test', [new DeltaUlspLag($loader, lag: 0)]), + loader: $loader, + lambda: 1.0, + ); + $model->predict(Carbon::parse('2024-06-03')); +})->throws(RuntimeException::class); + +it('throws when training data is too thin to fit the model', function () { + seedRidgeFixture(8); // not enough training rows after losing first 8 weeks to lags + $loader = new WeeklyPumpPriceLoader; + $model = new RidgeRegressionModel( + spec: new FeatureSpec('ridge-test', [ + new DeltaUlspLag($loader, lag: 3), + new UlspMinusMa8($loader), + ]), + loader: $loader, + lambda: 1.0, + ); + + $training = collect(range(0, 4))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); + $model->train($training); +})->throws(RuntimeException::class); + +it('beats the naive zero-change baseline on the synthetic fixture', function () { + seedRidgeFixture(30); + $loader = new WeeklyPumpPriceLoader; + + $features = [ + new DeltaUlspLag($loader, lag: 0), + new UlspMinusMa8($loader), + ]; + $ridge = new RidgeRegressionModel( + spec: new FeatureSpec('ridge-test', $features), + loader: $loader, + lambda: 1.0, + ); + $naive = new NaiveZeroChangeModel; + + $runner = new BacktestRunner; + + $ridgeResult = $runner->run( + $ridge, + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-04-29'), + evalStart: Carbon::parse('2024-05-06'), + evalEnd: Carbon::parse('2024-07-22'), + ); + + $naiveResult = $runner->run( + $naive, + trainStart: Carbon::parse('2024-01-01'), + trainEnd: Carbon::parse('2024-04-29'), + evalStart: Carbon::parse('2024-05-06'), + evalEnd: Carbon::parse('2024-07-22'), + ); + + expect((float) $ridgeResult->mae_pence) + ->toBeLessThan((float) $naiveResult->mae_pence); +}); diff --git a/tests/Unit/Services/Forecasting/Phase6Test.php b/tests/Unit/Services/Forecasting/Phase6Test.php new file mode 100644 index 0000000..551d828 --- /dev/null +++ b/tests/Unit/Services/Forecasting/Phase6Test.php @@ -0,0 +1,166 @@ +insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => 14000, + 'ulsd_pence' => 15000, + 'ulsp_duty_pence' => $duty, + 'ulsd_duty_pence' => $duty, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } +} + +it('DutyChangeDetector returns false when duty is constant across the window', function () { + seedFlatPrices(20); + + $detector = new DutyChangeDetector; + + expect($detector->isAdjacent(Carbon::parse('2024-03-04')))->toBeFalse(); +}); + +it('DutyChangeDetector returns true when duty changes within ±4 weeks', function () { + // 8 weeks at 57.95p, then 8 weeks at 52.95p + $start = Carbon::parse('2024-01-01'); + for ($i = 0; $i < 8; $i++) { + DB::table('weekly_pump_prices')->insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => 14000, + 'ulsd_pence' => 15000, + 'ulsp_duty_pence' => 5795, + 'ulsd_duty_pence' => 5795, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } + for ($i = 8; $i < 16; $i++) { + DB::table('weekly_pump_prices')->insert([ + 'date' => $start->copy()->addWeeks($i)->toDateString(), + 'ulsp_pence' => 14000, + 'ulsd_pence' => 15000, + 'ulsp_duty_pence' => 5295, + 'ulsd_duty_pence' => 5295, + 'ulsp_vat_pct' => 20, + 'ulsd_vat_pct' => 20, + ]); + } + + $detector = new DutyChangeDetector; + + // Target Mon at week 7 — change happens at week 8 → within ±4 weeks + expect($detector->isAdjacent(Carbon::parse('2024-02-19')))->toBeTrue(); +}); + +it('AccuracyHistory returns null when fewer than 4 outcomes', function () { + $history = new AccuracyHistory; + + DB::table('forecast_outcomes')->insert([ + 'forecast_for' => Carbon::now()->subWeeks(2)->toDateString(), + 'model_version' => 'm1', + 'predicted_class' => 'rising', + 'actual_class' => 'rising', + 'correct' => true, + 'abs_error_pence' => 50, + 'resolved_at' => now(), + ]); + + expect($history->trailingHitRate('m1'))->toBeNull(); +}); + +it('AccuracyHistory computes hit rate over the last 13 weeks', function () { + $history = new AccuracyHistory; + + // 4 correct, 1 wrong → 80% + foreach ([true, true, true, true, false] as $i => $correct) { + DB::table('forecast_outcomes')->insert([ + 'forecast_for' => Carbon::now()->subWeeks($i + 1)->toDateString(), + 'model_version' => 'm1', + 'predicted_class' => 'rising', + 'actual_class' => $correct ? 'rising' : 'falling', + 'correct' => $correct, + 'abs_error_pence' => 50, + 'resolved_at' => now(), + ]); + } + + expect($history->trailingHitRate('m1'))->toBe(0.8); +}); + +it('AccuracyHistory excludes outcomes outside the 13-week window', function () { + $history = new AccuracyHistory; + + // 4 inside window (correct), 4 outside (wrong) → 100% inside + foreach (range(1, 4) as $i) { + DB::table('forecast_outcomes')->insert([ + 'forecast_for' => Carbon::now()->subWeeks($i)->toDateString(), + 'model_version' => 'm1', + 'predicted_class' => 'rising', + 'actual_class' => 'rising', + 'correct' => true, + 'abs_error_pence' => 0, + 'resolved_at' => now(), + ]); + } + foreach (range(20, 23) as $i) { + DB::table('forecast_outcomes')->insert([ + 'forecast_for' => Carbon::now()->subWeeks($i)->toDateString(), + 'model_version' => 'm1', + 'predicted_class' => 'rising', + 'actual_class' => 'falling', + 'correct' => false, + 'abs_error_pence' => 100, + 'resolved_at' => now(), + ]); + } + + expect($history->trailingHitRate('m1'))->toBe(1.0); +}); + +it('OutcomeResolver pairs forecasts with actual deltas idempotently', function () { + seedFlatPrices(20); + + // Insert a forecast for week index 5 (2024-02-05) + DB::table('weekly_forecasts')->insert([ + 'forecast_for' => '2024-02-05', + 'model_version' => 'ridge-test', + 'direction' => 'rising', + 'magnitude_pence' => 80, + 'ridge_confidence' => 60, + 'flagged_duty_change' => false, + 'reasoning' => 'test', + 'generated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Override now() so the resolver sees the forecast as past. + Carbon::setTestNow('2024-02-12'); + + $resolver = new OutcomeResolver; + $first = $resolver->resolvePending(); + $second = $resolver->resolvePending(); + + expect($first)->toBe(1) + ->and($second)->toBe(0); // idempotent on re-run + + $row = DB::table('forecast_outcomes')->where('forecast_for', '2024-02-05')->first(); + expect($row->predicted_class)->toBe('rising') + ->and($row->actual_class)->toBe('flat') // flat data → actual delta = 0 + ->and((bool) $row->correct)->toBeFalse(); + + Carbon::setTestNow(); +}); diff --git a/tests/Unit/Services/Forecasting/UkBankHolidaysTest.php b/tests/Unit/Services/Forecasting/UkBankHolidaysTest.php new file mode 100644 index 0000000..e89b17a --- /dev/null +++ b/tests/Unit/Services/Forecasting/UkBankHolidaysTest.php @@ -0,0 +1,52 @@ +toHaveCount(8) + ->and(UkBankHolidays::forYear(2025))->toHaveCount(8); +}); + +it('computes Easter Monday correctly for 2024 (Apr 1)', function () { + $dates = array_map(fn ($d): string => $d->toDateString(), UkBankHolidays::forYear(2024)); + expect($dates)->toContain('2024-04-01'); // Easter Monday + expect($dates)->toContain('2024-03-29'); // Good Friday +}); + +it('computes the floating Mondays for 2024 correctly', function () { + $dates = array_map(fn ($d): string => $d->toDateString(), UkBankHolidays::forYear(2024)); + expect($dates)->toContain('2024-05-06'); // First Mon of May (Early May) + expect($dates)->toContain('2024-05-27'); // Last Mon of May (Spring) + expect($dates)->toContain('2024-08-26'); // Last Mon of August (Summer) +}); + +it('substitutes Christmas Day forward when it falls on a weekend (2022)', function () { + // 2022: Christmas was a Sunday, Boxing Day Monday → Christmas observed Tue Dec 27, Boxing observed Mon Dec 26. + $dates = array_map(fn ($d): string => $d->toDateString(), UkBankHolidays::forYear(2022)); + expect($dates)->toContain('2022-12-26') // Boxing + ->and($dates)->toContain('2022-12-27'); // Christmas substituted +}); + +it('returns true when a target Monday is itself a bank holiday (Easter Monday 2024)', function () { + $monday = Carbon::parse('2024-04-01'); + expect(UkBankHolidays::holidayWithin($monday, 7))->toBeTrue(); +}); + +it('returns true when a bank holiday falls within the next 7 days', function () { + // Mon 2024-04-01 is Easter Monday. The Monday before (2024-03-25) is pre-bank-holiday week. + $weekBefore = Carbon::parse('2024-03-25'); + expect(UkBankHolidays::holidayWithin($weekBefore, 7))->toBeTrue(); +}); + +it('returns false for a quiet stretch with no holidays in the window', function () { + // Mid-July 2024 — no UK bank holidays in this 7-day window. + $monday = Carbon::parse('2024-07-15'); + expect(UkBankHolidays::holidayWithin($monday, 7))->toBeFalse(); +}); + +it('handles a window that crosses a year boundary', function () { + // Mon 2024-12-30 → window includes New Year's Day 2025 (Wed Jan 1). + $monday = Carbon::parse('2024-12-30'); + expect(UkBankHolidays::holidayWithin($monday, 7))->toBeTrue(); +}); diff --git a/tests/Unit/Services/Forecasting/VolatilityRegimeServiceTest.php b/tests/Unit/Services/Forecasting/VolatilityRegimeServiceTest.php new file mode 100644 index 0000000..ac9cbc3 --- /dev/null +++ b/tests/Unit/Services/Forecasting/VolatilityRegimeServiceTest.php @@ -0,0 +1,161 @@ +evaluate(); + + expect($result)->toBeNull() + ->and(VolatilityRegime::query()->count())->toBe(0); +}); + +it('flips ON when Brent moves more than 3% close-to-close', function (): void { + BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); + BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 84.00]); // +5% + + $service = makeVolatilityService(); + + $row = $service->evaluate(); + + expect($row)->not->toBeNull() + ->and($row->trigger)->toBe('brent_move') + ->and($row->active)->toBeTrue(); +}); + +it('does NOT flip on a 2% Brent move (below threshold)', function (): void { + BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); + BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 81.50]); // +1.875% + + $service = makeVolatilityService(); + + expect($service->evaluate())->toBeNull(); +}); + +it('flips ON when the most recent llm_overlay flags a major impact event', function (): void { + LlmOverlay::query()->create([ + 'ran_at' => now(), + 'forecast_for_week' => Carbon::now()->next(Carbon::MONDAY)->toDateString(), + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning' => 'OPEC unexpected cut.', + 'events_json' => [['headline' => 'OPEC cut', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising']], + 'agrees_with_ridge' => true, + 'major_impact_event' => true, + 'volatility_flag_on' => false, + 'search_used' => true, + ]); + + $service = makeVolatilityService(); + + $row = $service->evaluate(); + + expect($row)->not->toBeNull() + ->and($row->trigger)->toBe('llm_event'); +}); + +it('does NOT flip on llm_overlay when no URL is verified', function (): void { + LlmOverlay::query()->create([ + 'ran_at' => now(), + 'forecast_for_week' => Carbon::now()->next(Carbon::MONDAY)->toDateString(), + 'direction' => 'rising', + 'confidence' => 60, + 'reasoning' => '...', + 'events_json' => [['headline' => 'OPEC cut', 'source' => 'Reuters', 'url' => '', 'impact' => 'rising']], + 'agrees_with_ridge' => true, + 'major_impact_event' => true, + 'volatility_flag_on' => false, + 'search_used' => true, + ]); + + $service = makeVolatilityService(); + + expect($service->evaluate())->toBeNull(); +}); + +it('flips ON when a watched_event covers today', function (): void { + WatchedEvent::query()->create([ + 'label' => 'Iran tensions', + 'starts_at' => Carbon::now()->subDay(), + 'ends_at' => Carbon::now()->addWeek(), + 'notes' => 'manually flagged', + ]); + + $service = makeVolatilityService(); + + $row = $service->evaluate(); + + expect($row)->not->toBeNull() + ->and($row->trigger)->toBe('manual') + ->and($row->trigger_detail)->toContain('Iran tensions'); +}); + +it('flips OFF when no triggers fire while a regime is active', function (): void { + $existing = VolatilityRegime::query()->create([ + 'flipped_on_at' => now()->subDay(), + 'flipped_off_at' => null, + 'trigger' => 'brent_move', + 'trigger_detail' => 'Brent +4.2%', + 'active' => true, + ]); + + $service = makeVolatilityService(); + + $result = $service->evaluate(); + + expect($result)->toBeNull(); + $existing->refresh(); + expect($existing->active)->toBeFalse() + ->and($existing->flipped_off_at)->not->toBeNull(); +}); + +it('keeps the existing regime when a trigger still fires', function (): void { + BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); + BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 84.00]); + + $existing = VolatilityRegime::query()->create([ + 'flipped_on_at' => now()->subHour(), + 'flipped_off_at' => null, + 'trigger' => 'brent_move', + 'trigger_detail' => 'Brent +5%', + 'active' => true, + ]); + + $service = makeVolatilityService(); + + $result = $service->evaluate(); + + expect($result?->id)->toBe($existing->id) + ->and(VolatilityRegime::query()->count())->toBe(1); +}); + +it('skips station_churn trigger when feature flag is off (default)', function (): void { + Config::set('services.forecasting.station_churn_enabled', false); + $service = makeVolatilityService(); + + expect($service->evaluate())->toBeNull(); +});