Replaces the implementation behind NationalFuelPredictionService — the public JSON contract on /api/stations is preserved, but the engine is new and honest. Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md): 1. Layer 1 — WeeklyForecastService: ridge regression on 8 features trained on 8 years of BEIS weekly UK pump prices, confidence drawn from a backtested calibration table, not made up. 2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over station_prices_current. Never speaks about the future. 3. Layer 3 — verdict via rule gates, not confidence multipliers. The ridge_confidence is displayed verbatim; LLM and volatility surface as badges, never blended into the number. 4. Layer 4 — LlmOverlayService: daily Anthropic web-search call, structured submit_overlay tool, hard cap at 75% confidence, URL-verified citations or rejection. 5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the active flag, OR-combined triggers (Brent move >3%, LLM major impact, station churn (gated), watched_events). Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the 8x8 normal-equation matrix. No external ML dependency. Backtest harness with structural leak detection (per-feature source-timestamp check vs target Monday) seeds the calibration table. Backtest gate (62–68% directional accuracy on the 130-week hold-out) ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change baseline by ~30pp on real data. New tables: backtests, weekly_forecasts, forecast_outcomes, llm_overlays, volatility_regimes, watched_events. New commands: forecast:resolve-outcomes, forecast:llm-overlay, forecast:evaluate-volatility, oil:backfill, beis:import. Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK, forecast:evaluate-volatility hourly, beis:import Mon 09:30, forecast:resolve-outcomes Mon 10:00. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
4.4 KiB
PHP
147 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Forecasting;
|
|
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonInterface;
|
|
|
|
/**
|
|
* UK England-and-Wales bank holiday calendar.
|
|
*
|
|
* Computed deterministically from year (no external dependency, no
|
|
* hardcoded list to maintain).
|
|
*
|
|
* Includes the eight statutory holidays:
|
|
* New Year's Day, Good Friday, Easter Monday,
|
|
* Early May Bank Holiday, Spring Bank Holiday, Summer Bank Holiday,
|
|
* Christmas Day, Boxing Day
|
|
*
|
|
* Substitution rules: when a fixed-date holiday falls on a weekend,
|
|
* it's observed on the next non-holiday weekday (cascades for
|
|
* Christmas+Boxing landing on Sat+Sun).
|
|
*/
|
|
final class UkBankHolidays
|
|
{
|
|
/**
|
|
* Sorted list of bank holiday dates for a year, after substitution.
|
|
*
|
|
* @return array<int, Carbon>
|
|
*/
|
|
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<int, CarbonInterface> $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();
|
|
}
|
|
}
|
|
}
|