feat(forecasting): build calibrated weekly forecast stack with LLM overlay and volatility detector

Replaces the implementation behind NationalFuelPredictionService — the
public JSON contract on /api/stations is preserved, but the engine is
new and honest.

Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md):
1. Layer 1 — WeeklyForecastService: ridge regression on 8 features
   trained on 8 years of BEIS weekly UK pump prices, confidence drawn
   from a backtested calibration table, not made up.
2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over
   station_prices_current. Never speaks about the future.
3. Layer 3 — verdict via rule gates, not confidence multipliers. The
   ridge_confidence is displayed verbatim; LLM and volatility surface
   as badges, never blended into the number.
4. Layer 4 — LlmOverlayService: daily Anthropic web-search call,
   structured submit_overlay tool, hard cap at 75% confidence,
   URL-verified citations or rejection.
5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the
   active flag, OR-combined triggers (Brent move >3%, LLM major
   impact, station churn (gated), watched_events).

Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the
8x8 normal-equation matrix. No external ML dependency. Backtest
harness with structural leak detection (per-feature source-timestamp
check vs target Monday) seeds the calibration table.

Backtest gate (62–68% directional accuracy on the 130-week hold-out)
ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change
baseline by ~30pp on real data.

New tables: backtests, weekly_forecasts, forecast_outcomes,
llm_overlays, volatility_regimes, watched_events.

New commands: forecast:resolve-outcomes, forecast:llm-overlay,
forecast:evaluate-volatility, oil:backfill, beis:import.

Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK,
forecast:evaluate-volatility hourly, beis:import Mon 09:30,
forecast:resolve-outcomes Mon 10:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-05-03 08:40:05 +01:00
parent d13a29df01
commit ddd591ad47
63 changed files with 5109 additions and 13 deletions

View File

@@ -0,0 +1,86 @@
<?php
use App\Services\Forecasting\LinearAlgebra;
it('transposes a 2x3 to a 3x2', function () {
$m = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
expect(LinearAlgebra::transpose($m))->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);