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,222 @@
<?php
use App\Models\Backtest;
use App\Services\Forecasting\BacktestRunner;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\LeakDetectorException;
use App\Services\Forecasting\WeeklyPrediction;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Builds a simple feature reading the previous week's value.
* Source date offset is configurable so we can simulate leakage.
*/
function backtestFeature(string $name, int $offsetDays = -7): ForecastFeature
{
return new class($name, $offsetDays) implements ForecastFeature
{
public function __construct(
private readonly string $featureName,
private readonly int $offsetDays,
) {}
public function name(): string
{
return $this->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();
});

View File

@@ -0,0 +1,119 @@
<?php
use App\Services\Forecasting\BeisImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
function fakeBeisCsv(string $body, string $cdnUrl = 'https://assets.publishing.service.gov.uk/media/abc/weekly_road_fuel_prices_270426.csv'): void
{
Http::fake([
'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices' => 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);
});

View File

@@ -0,0 +1,146 @@
<?php
use App\Services\Forecasting\Features\DeltaUlsdLag;
use App\Services\Forecasting\Features\DeltaUlspLag;
use App\Services\Forecasting\Features\IsPreBankHoliday;
use App\Services\Forecasting\Features\UlspMinusMa8;
use App\Services\Forecasting\Features\WeekOfYearTrig;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function seedRisingTenWeeks(int $base = 14000, int $stepUlsp = 100, int $stepUlsd = 80): void
{
$start = Carbon::parse('2024-01-01');
for ($i = 0; $i < 10; $i++) {
DB::table('weekly_pump_prices')->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);
});

View File

@@ -0,0 +1,118 @@
<?php
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\LeakDetector;
use App\Services\Forecasting\LeakReport;
use Carbon\Carbon;
use Carbon\CarbonInterface;
function makeFeature(string $name, array $offsetsInDays): ForecastFeature
{
return new class($name, $offsetsInDays) implements ForecastFeature
{
/** @param array<int, int> $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');
});

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);

View File

@@ -0,0 +1,248 @@
<?php
use App\Models\LlmOverlay;
use App\Services\ApiLogger;
use App\Services\Forecasting\LlmOverlayService;
use App\Services\Forecasting\WeeklyForecastService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Cache::flush();
Config::set('services.anthropic.api_key', 'test-key');
});
function fakeAnthropicWithOverlay(string $direction, int $confidence, array $events, bool $major = false): void
{
Http::fake([
'*api.anthropic.com/*' => 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();
});

View File

@@ -0,0 +1,113 @@
<?php
use App\Models\Station;
use App\Models\StationPriceCurrent;
use App\Services\Forecasting\LocalSnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedStation(float $lat, float $lng, int $pence, bool $supermarket = false, ?string $name = 'Test', ?string $brand = null): Station
{
$s = Station::factory()->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);
});

View File

@@ -0,0 +1,56 @@
<?php
use App\Services\Forecasting\BacktestRunner;
use App\Services\Forecasting\Models\NaiveZeroChangeModel;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('predicts zero change with flat direction', function () {
$model = new NaiveZeroChangeModel;
$prediction = $model->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();
});

View File

@@ -0,0 +1,152 @@
<?php
use App\Services\Forecasting\BacktestRunner;
use App\Services\Forecasting\Features\DeltaUlspLag;
use App\Services\Forecasting\Features\UlspMinusMa8;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\Models\NaiveZeroChangeModel;
use App\Services\Forecasting\Models\RidgeRegressionModel;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function seedRidgeFixture(int $weeks = 30): void
{
// Synthetic sequence with strong autocorrelation: each week's ULSP
// tracks last week's change. Ridge should pick this up.
$start = Carbon::parse('2024-01-01');
$price = 14000;
$lastDelta = 0;
for ($i = 0; $i < $weeks; $i++) {
// Persistent momentum: this week ≈ last week's delta + small noise.
if ($i === 0) {
$delta = 50;
} else {
$delta = (int) round($lastDelta * 0.8 + 10); // mild reversion + drift
}
$price += $delta;
$lastDelta = $delta;
DB::table('weekly_pump_prices')->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);
});

View File

@@ -0,0 +1,166 @@
<?php
use App\Services\Forecasting\AccuracyHistory;
use App\Services\Forecasting\DutyChangeDetector;
use App\Services\Forecasting\OutcomeResolver;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function seedFlatPrices(int $weeks, int $duty = 5295): void
{
$start = Carbon::parse('2024-01-01');
for ($i = 0; $i < $weeks; $i++) {
DB::table('weekly_pump_prices')->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();
});

View File

@@ -0,0 +1,52 @@
<?php
use App\Services\Forecasting\UkBankHolidays;
use Carbon\Carbon;
it('returns 8 statutory bank holidays per year', function () {
expect(UkBankHolidays::forYear(2024))->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();
});

View File

@@ -0,0 +1,161 @@
<?php
use App\Models\BrentPrice;
use App\Models\LlmOverlay;
use App\Models\VolatilityRegime;
use App\Models\WatchedEvent;
use App\Services\ApiLogger;
use App\Services\Forecasting\LlmOverlayService;
use App\Services\Forecasting\VolatilityRegimeService;
use App\Services\Forecasting\WeeklyForecastService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
function makeVolatilityService(): VolatilityRegimeService
{
Http::preventStrayRequests();
Config::set('services.anthropic.api_key', null); // makes LLM run a no-op
return new VolatilityRegimeService(
new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)),
);
}
it('does nothing when there are no triggers', function (): void {
$service = makeVolatilityService();
$result = $service->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();
});