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:
222
tests/Unit/Services/Forecasting/BacktestRunnerTest.php
Normal file
222
tests/Unit/Services/Forecasting/BacktestRunnerTest.php
Normal 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();
|
||||
});
|
||||
119
tests/Unit/Services/Forecasting/BeisImporterTest.php
Normal file
119
tests/Unit/Services/Forecasting/BeisImporterTest.php
Normal 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);
|
||||
});
|
||||
146
tests/Unit/Services/Forecasting/Features/FeaturesTest.php
Normal file
146
tests/Unit/Services/Forecasting/Features/FeaturesTest.php
Normal 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);
|
||||
});
|
||||
118
tests/Unit/Services/Forecasting/LeakDetectorTest.php
Normal file
118
tests/Unit/Services/Forecasting/LeakDetectorTest.php
Normal 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');
|
||||
});
|
||||
86
tests/Unit/Services/Forecasting/LinearAlgebraTest.php
Normal file
86
tests/Unit/Services/Forecasting/LinearAlgebraTest.php
Normal 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);
|
||||
248
tests/Unit/Services/Forecasting/LlmOverlayServiceTest.php
Normal file
248
tests/Unit/Services/Forecasting/LlmOverlayServiceTest.php
Normal 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();
|
||||
});
|
||||
113
tests/Unit/Services/Forecasting/LocalSnapshotServiceTest.php
Normal file
113
tests/Unit/Services/Forecasting/LocalSnapshotServiceTest.php
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
166
tests/Unit/Services/Forecasting/Phase6Test.php
Normal file
166
tests/Unit/Services/Forecasting/Phase6Test.php
Normal 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();
|
||||
});
|
||||
52
tests/Unit/Services/Forecasting/UkBankHolidaysTest.php
Normal file
52
tests/Unit/Services/Forecasting/UkBankHolidaysTest.php
Normal 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();
|
||||
});
|
||||
161
tests/Unit/Services/Forecasting/VolatilityRegimeServiceTest.php
Normal file
161
tests/Unit/Services/Forecasting/VolatilityRegimeServiceTest.php
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user