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:
33
app/Console/Commands/BackfillOilPrices.php
Normal file
33
app/Console/Commands/BackfillOilPrices.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BrentPriceFetcher;
|
||||||
|
use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
|
||||||
|
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
|
||||||
|
class BackfillOilPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BrentPriceFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
$from = (string) $this->option('from');
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
|
||||||
|
$this->info("Backfilling Brent ({$from} → {$to}) from FRED...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $fetcher->backfillFromFred($from, $to);
|
||||||
|
$this->info(sprintf('Upserted %d Brent rows.', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
$this->error('FRED backfill failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\VolatilityRegimeService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:evaluate-volatility')]
|
||||||
|
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
|
||||||
|
class EvaluateVolatilityRegime extends Command
|
||||||
|
{
|
||||||
|
public function handle(VolatilityRegimeService $service): int
|
||||||
|
{
|
||||||
|
$regime = $service->evaluate();
|
||||||
|
|
||||||
|
if ($regime === null) {
|
||||||
|
$this->info('Volatility regime: OFF');
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Volatility regime: ON (trigger=%s, since %s)',
|
||||||
|
$regime->trigger,
|
||||||
|
$regime->flipped_on_at->toIso8601String(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\BeisImporter;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('beis:import')]
|
||||||
|
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
|
||||||
|
class ImportBeisFuelPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BeisImporter $importer): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $importer->import();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('BEIS import failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Imported %d rows from %s — latest date: %s.',
|
||||||
|
$result['parsed'],
|
||||||
|
$result['csv_url'],
|
||||||
|
$result['latest_date'],
|
||||||
|
));
|
||||||
|
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\OutcomeResolver;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:resolve-outcomes')]
|
||||||
|
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
|
||||||
|
class ResolveForecastOutcomes extends Command
|
||||||
|
{
|
||||||
|
public function handle(OutcomeResolver $resolver): int
|
||||||
|
{
|
||||||
|
$count = $resolver->resolvePending();
|
||||||
|
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Console/Commands/RunLlmOverlay.php
Normal file
34
app/Console/Commands/RunLlmOverlay.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\LlmOverlayService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
|
||||||
|
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
|
||||||
|
class RunLlmOverlay extends Command
|
||||||
|
{
|
||||||
|
public function handle(LlmOverlayService $service): int
|
||||||
|
{
|
||||||
|
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
|
||||||
|
$row->id,
|
||||||
|
$row->direction,
|
||||||
|
$row->confidence,
|
||||||
|
$row->major_impact_event ? 'YES' : 'no',
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Models/Backtest.php
Normal file
45
app/Models/Backtest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\BacktestFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'model_version',
|
||||||
|
'features_json',
|
||||||
|
'coefficients_json',
|
||||||
|
'train_start',
|
||||||
|
'train_end',
|
||||||
|
'eval_start',
|
||||||
|
'eval_end',
|
||||||
|
'directional_accuracy',
|
||||||
|
'mae_pence',
|
||||||
|
'calibration_table',
|
||||||
|
'leak_suspected',
|
||||||
|
'ran_at',
|
||||||
|
])]
|
||||||
|
class Backtest extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<BacktestFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'features_json' => 'array',
|
||||||
|
'coefficients_json' => 'array',
|
||||||
|
'calibration_table' => 'array',
|
||||||
|
'train_start' => 'date',
|
||||||
|
'train_end' => 'date',
|
||||||
|
'eval_start' => 'date',
|
||||||
|
'eval_end' => 'date',
|
||||||
|
'directional_accuracy' => 'decimal:2',
|
||||||
|
'mae_pence' => 'decimal:2',
|
||||||
|
'leak_suspected' => 'boolean',
|
||||||
|
'ran_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/ForecastOutcome.php
Normal file
36
app/Models/ForecastOutcome.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'forecast_for',
|
||||||
|
'model_version',
|
||||||
|
'predicted_class',
|
||||||
|
'actual_class',
|
||||||
|
'correct',
|
||||||
|
'abs_error_pence',
|
||||||
|
'resolved_at',
|
||||||
|
])]
|
||||||
|
class ForecastOutcome extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'forecast_for';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'forecast_for' => 'date',
|
||||||
|
'correct' => 'boolean',
|
||||||
|
'abs_error_pence' => 'integer',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/LlmOverlay.php
Normal file
35
app/Models/LlmOverlay.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'ran_at',
|
||||||
|
'forecast_for_week',
|
||||||
|
'direction',
|
||||||
|
'confidence',
|
||||||
|
'reasoning',
|
||||||
|
'events_json',
|
||||||
|
'agrees_with_ridge',
|
||||||
|
'major_impact_event',
|
||||||
|
'volatility_flag_on',
|
||||||
|
'search_used',
|
||||||
|
])]
|
||||||
|
class LlmOverlay extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ran_at' => 'datetime',
|
||||||
|
'forecast_for_week' => 'date',
|
||||||
|
'confidence' => 'integer',
|
||||||
|
'events_json' => 'array',
|
||||||
|
'agrees_with_ridge' => 'boolean',
|
||||||
|
'major_impact_event' => 'boolean',
|
||||||
|
'volatility_flag_on' => 'boolean',
|
||||||
|
'search_used' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/VolatilityRegime.php
Normal file
30
app/Models/VolatilityRegime.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'flipped_on_at',
|
||||||
|
'flipped_off_at',
|
||||||
|
'trigger',
|
||||||
|
'trigger_detail',
|
||||||
|
'active',
|
||||||
|
])]
|
||||||
|
class VolatilityRegime extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'flipped_on_at' => 'datetime',
|
||||||
|
'flipped_off_at' => 'datetime',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentlyActive(): ?self
|
||||||
|
{
|
||||||
|
return static::query()->where('active', true)->orderByDesc('flipped_on_at')->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/WatchedEvent.php
Normal file
23
app/Models/WatchedEvent.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'label',
|
||||||
|
'starts_at',
|
||||||
|
'ends_at',
|
||||||
|
'notes',
|
||||||
|
])]
|
||||||
|
class WatchedEvent extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'ends_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/WeeklyForecast.php
Normal file
30
app/Models/WeeklyForecast.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'forecast_for',
|
||||||
|
'model_version',
|
||||||
|
'direction',
|
||||||
|
'magnitude_pence',
|
||||||
|
'ridge_confidence',
|
||||||
|
'flagged_duty_change',
|
||||||
|
'reasoning',
|
||||||
|
'generated_at',
|
||||||
|
])]
|
||||||
|
class WeeklyForecast extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'forecast_for' => 'date',
|
||||||
|
'magnitude_pence' => 'integer',
|
||||||
|
'ridge_confidence' => 'integer',
|
||||||
|
'flagged_duty_change' => 'boolean',
|
||||||
|
'generated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,4 +41,24 @@ final readonly class BrentPriceFetcher
|
|||||||
|
|
||||||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot Brent backfill via FRED's observation_start/end. Used to
|
||||||
|
* seed `brent_prices` going back to 2018 so Phase 9's volatility
|
||||||
|
* detector and Phase 8's LLM overlay have proper context.
|
||||||
|
*
|
||||||
|
* @return int rows inserted/updated
|
||||||
|
*/
|
||||||
|
public function backfillFromFred(string $from, string $to): int
|
||||||
|
{
|
||||||
|
$rows = $this->fred->fetchRange($from, $to);
|
||||||
|
|
||||||
|
if ($rows === null) {
|
||||||
|
throw new BrentPriceFetchException("FRED backfill ({$from} → {$to}) returned no data");
|
||||||
|
}
|
||||||
|
|
||||||
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
|
|
||||||
|
return count($rows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,50 @@ final class FredBrentPriceSource
|
|||||||
*/
|
*/
|
||||||
public function fetch(): ?array
|
public function fetch(): ?array
|
||||||
{
|
{
|
||||||
|
return $this->call([
|
||||||
|
'sort_order' => 'desc',
|
||||||
|
'limit' => 30,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill range (inclusive). FRED's `observation_start` /
|
||||||
|
* `observation_end` parameters expect ISO dates (YYYY-MM-DD).
|
||||||
|
* Returns null when the range is empty (e.g. all weekends/holidays).
|
||||||
|
*
|
||||||
|
* @return array{date: string, price_usd: float}[]|null
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException
|
||||||
|
*/
|
||||||
|
public function fetchRange(string $from, string $to): ?array
|
||||||
|
{
|
||||||
|
return $this->call([
|
||||||
|
'observation_start' => $from,
|
||||||
|
'observation_end' => $to,
|
||||||
|
'sort_order' => 'asc',
|
||||||
|
'limit' => 100000,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar> $extraParams
|
||||||
|
* @return array{date: string, price_usd: float}[]|null
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException
|
||||||
|
*/
|
||||||
|
private function call(array $extraParams): ?array
|
||||||
|
{
|
||||||
|
$params = array_merge([
|
||||||
|
'series_id' => 'DCOILBRENTEU',
|
||||||
|
'api_key' => config('services.fred.api_key'),
|
||||||
|
'file_type' => 'json',
|
||||||
|
], $extraParams);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(60)
|
||||||
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||||
->throw()
|
->throw()
|
||||||
->get(self::URL, [
|
->get(self::URL, $params));
|
||||||
'series_id' => 'DCOILBRENTEU',
|
|
||||||
'api_key' => config('services.fred.api_key'),
|
|
||||||
'sort_order' => 'desc',
|
|
||||||
'limit' => 30,
|
|
||||||
'file_type' => 'json',
|
|
||||||
]));
|
|
||||||
} catch (ConnectionException $e) {
|
} catch (ConnectionException $e) {
|
||||||
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
|
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
|
||||||
} catch (RequestException $e) {
|
} catch (RequestException $e) {
|
||||||
|
|||||||
36
app/Services/Forecasting/AccuracyHistory.php
Normal file
36
app/Services/Forecasting/AccuracyHistory.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing-13-week hit rate for a model_version. Read from
|
||||||
|
* `forecast_outcomes`. Returns null when fewer than 4 outcomes are
|
||||||
|
* available (a single bad week would otherwise dominate the ratio).
|
||||||
|
*/
|
||||||
|
final class AccuracyHistory
|
||||||
|
{
|
||||||
|
private const int WEEKS = 13;
|
||||||
|
|
||||||
|
private const int MIN_OUTCOMES = 4;
|
||||||
|
|
||||||
|
public function trailingHitRate(string $modelVersion): ?float
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subWeeks(self::WEEKS)->toDateString();
|
||||||
|
|
||||||
|
$row = DB::table('forecast_outcomes')
|
||||||
|
->where('model_version', $modelVersion)
|
||||||
|
->where('forecast_for', '>=', $cutoff)
|
||||||
|
->selectRaw('COUNT(*) as total, SUM(CASE WHEN correct THEN 1 ELSE 0 END) as correct')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$total = (int) ($row->total ?? 0);
|
||||||
|
if ($total < self::MIN_OUTCOMES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($row->correct ?? 0) / $total;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/Forecasting/BacktestRunner.php
Normal file
162
app/Services/Forecasting/BacktestRunner.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a WeeklyForecastModel through a train/eval split and persists
|
||||||
|
* the result to the `backtests` table.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Generate the training and eval Monday lists from the date ranges.
|
||||||
|
* 2. Run LeakDetector against every Monday × every feature. Refuse to
|
||||||
|
* train if any source date is on or after a target Monday.
|
||||||
|
* 3. Train the model.
|
||||||
|
* 4. For each eval Monday: predict, look up actual ΔULSP from
|
||||||
|
* `weekly_pump_prices`, score directional accuracy + abs error.
|
||||||
|
* 5. Persist a Backtest row, return it.
|
||||||
|
*
|
||||||
|
* The `leak_suspected` flag is a *secondary* smell test (true when
|
||||||
|
* directional_accuracy > 75). Primary leak defence is step 2.
|
||||||
|
*/
|
||||||
|
final class BacktestRunner
|
||||||
|
{
|
||||||
|
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LeakDetector $leakDetector = new LeakDetector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(
|
||||||
|
WeeklyForecastModel $model,
|
||||||
|
CarbonInterface $trainStart,
|
||||||
|
CarbonInterface $trainEnd,
|
||||||
|
CarbonInterface $evalStart,
|
||||||
|
CarbonInterface $evalEnd,
|
||||||
|
): Backtest {
|
||||||
|
$trainingMondays = $this->mondaysBetween($trainStart, $trainEnd);
|
||||||
|
$evalMondays = $this->mondaysBetween($evalStart, $evalEnd);
|
||||||
|
|
||||||
|
$spec = $model->featureSpec();
|
||||||
|
$report = $this->leakDetector->validate($spec, [...$trainingMondays, ...$evalMondays]);
|
||||||
|
if ($report->hasLeaks()) {
|
||||||
|
throw new LeakDetectorException($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->train($trainingMondays);
|
||||||
|
|
||||||
|
$correct = 0;
|
||||||
|
$totalScored = 0;
|
||||||
|
$absErrors = [];
|
||||||
|
$bins = [];
|
||||||
|
|
||||||
|
foreach ($evalMondays as $monday) {
|
||||||
|
$actualDelta = $this->actualDeltaPence($monday);
|
||||||
|
if ($actualDelta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prediction = $model->predict($monday);
|
||||||
|
$actualDirection = $this->classifyDirection($actualDelta);
|
||||||
|
$hit = $prediction->direction === $actualDirection;
|
||||||
|
|
||||||
|
$totalScored++;
|
||||||
|
$absErrors[] = abs($prediction->magnitudePence - $actualDelta);
|
||||||
|
if ($hit) {
|
||||||
|
$correct++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
|
||||||
|
$bins[$bin] ??= ['correct' => 0, 'total' => 0];
|
||||||
|
$bins[$bin]['total']++;
|
||||||
|
if ($hit) {
|
||||||
|
$bins[$bin]['correct']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$directionalAccuracy = $totalScored === 0
|
||||||
|
? null
|
||||||
|
: round(($correct / $totalScored) * 100, 2);
|
||||||
|
|
||||||
|
$maePence = $absErrors === []
|
||||||
|
? null
|
||||||
|
: round((array_sum($absErrors) / count($absErrors)) / 100, 2);
|
||||||
|
|
||||||
|
$calibrationTable = [];
|
||||||
|
foreach ($bins as $key => $b) {
|
||||||
|
$calibrationTable[$key] = round($b['correct'] / $b['total'], 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Backtest::create([
|
||||||
|
'model_version' => $spec->modelVersion(),
|
||||||
|
'features_json' => $spec->toArray(),
|
||||||
|
'coefficients_json' => $model->coefficients(),
|
||||||
|
'train_start' => $trainStart->toDateString(),
|
||||||
|
'train_end' => $trainEnd->toDateString(),
|
||||||
|
'eval_start' => $evalStart->toDateString(),
|
||||||
|
'eval_end' => $evalEnd->toDateString(),
|
||||||
|
'directional_accuracy' => $directionalAccuracy,
|
||||||
|
'mae_pence' => $maePence,
|
||||||
|
'calibration_table' => $calibrationTable,
|
||||||
|
'leak_suspected' => $directionalAccuracy !== null && $directionalAccuracy > 75.0,
|
||||||
|
'ran_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, CarbonInterface> */
|
||||||
|
private function mondaysBetween(CarbonInterface $start, CarbonInterface $end): array
|
||||||
|
{
|
||||||
|
$mondays = [];
|
||||||
|
$cursor = $start->copy()->startOfDay();
|
||||||
|
$boundary = $end->copy()->startOfDay();
|
||||||
|
|
||||||
|
while ($cursor->lessThanOrEqualTo($boundary)) {
|
||||||
|
if ($cursor->dayOfWeek === CarbonInterface::MONDAY) {
|
||||||
|
$mondays[] = $cursor->copy();
|
||||||
|
}
|
||||||
|
$cursor = $cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mondays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
$current = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', $targetMonday->toDateString())
|
||||||
|
->value('ulsp_pence');
|
||||||
|
$previous = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', $targetMonday->copy()->subDays(7)->toDateString())
|
||||||
|
->value('ulsp_pence');
|
||||||
|
|
||||||
|
if ($current === null || $previous === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($current - $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classifyDirection(float $deltaPence): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
|
||||||
|
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
|
||||||
|
default => 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bucketForMagnitude(float $magnitudePence): string
|
||||||
|
{
|
||||||
|
$abs = abs($magnitudePence);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$abs < 50.0 => '0.0-0.5p',
|
||||||
|
$abs < 100.0 => '0.5-1.0p',
|
||||||
|
default => '1.0p+',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Services/Forecasting/BeisImporter.php
Normal file
138
app/Services/Forecasting/BeisImporter.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the latest "Weekly road fuel prices (CSV) 2018 to 2026"
|
||||||
|
* attachment from gov.uk's content API and upserts into
|
||||||
|
* `weekly_pump_prices`.
|
||||||
|
*
|
||||||
|
* Idempotent: re-running on a day with no new publication is a no-op
|
||||||
|
* (rows match by primary key `date`, content is unchanged).
|
||||||
|
*
|
||||||
|
* The forecast cache is busted at the end so the next API hit retrains
|
||||||
|
* the ridge model on the fresh row.
|
||||||
|
*/
|
||||||
|
final class BeisImporter
|
||||||
|
{
|
||||||
|
private const string API_URL = 'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices';
|
||||||
|
|
||||||
|
private const string ATTACHMENT_TITLE = 'Weekly road fuel prices (CSV) 2018 to 2026';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* csv_url: string,
|
||||||
|
* parsed: int,
|
||||||
|
* upserted: int,
|
||||||
|
* latest_date: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function import(): array
|
||||||
|
{
|
||||||
|
$url = $this->resolveCsvUrl();
|
||||||
|
$csv = $this->downloadCsv($url);
|
||||||
|
$rows = $this->parse($csv);
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
throw new RuntimeException('BEIS CSV parsed empty — check delimiter / encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('weekly_pump_prices')->upsert(
|
||||||
|
$rows,
|
||||||
|
['date'],
|
||||||
|
['ulsp_pence', 'ulsd_pence', 'ulsp_duty_pence', 'ulsd_duty_pence', 'ulsp_vat_pct', 'ulsd_vat_pct'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$latest = (string) collect($rows)->pluck('date')->sortDesc()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'csv_url' => $url,
|
||||||
|
'parsed' => count($rows),
|
||||||
|
'upserted' => count($rows),
|
||||||
|
'latest_date' => $latest,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCsvUrl(): string
|
||||||
|
{
|
||||||
|
$response = Http::timeout(15)->acceptJson()->get(self::API_URL);
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
$attachments = $response->json('details.attachments', []);
|
||||||
|
foreach ($attachments as $a) {
|
||||||
|
if (($a['title'] ?? null) === self::ATTACHMENT_TITLE) {
|
||||||
|
$url = $a['url'] ?? null;
|
||||||
|
if (! is_string($url) || $url === '') {
|
||||||
|
throw new RuntimeException('BEIS attachment had empty URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'gov.uk content API did not return an attachment titled %s',
|
||||||
|
self::ATTACHMENT_TITLE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadCsv(string $url): string
|
||||||
|
{
|
||||||
|
$response = Http::timeout(60)->get($url);
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
return $response->body();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, int|string>>
|
||||||
|
*/
|
||||||
|
private function parse(string $csv): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $csv);
|
||||||
|
if ($lines === false || count($lines) < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header.
|
||||||
|
array_shift($lines);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols = str_getcsv($line, escape: '\\');
|
||||||
|
if (count($cols) < 7) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DateTime::createFromFormat('d/m/Y', trim($cols[0]));
|
||||||
|
if ($date === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'date' => $date->format('Y-m-d'),
|
||||||
|
'ulsp_pence' => (int) round(((float) $cols[1]) * 100),
|
||||||
|
'ulsd_pence' => (int) round(((float) $cols[2]) * 100),
|
||||||
|
'ulsp_duty_pence' => (int) round(((float) $cols[3]) * 100),
|
||||||
|
'ulsd_duty_pence' => (int) round(((float) $cols[4]) * 100),
|
||||||
|
'ulsp_vat_pct' => (int) $cols[5],
|
||||||
|
'ulsd_vat_pct' => (int) $cols[6],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal file
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Contracts;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single feature in a weekly forecast model.
|
||||||
|
*
|
||||||
|
* Implementations must be deterministic for a given target Monday and
|
||||||
|
* must declare every source date they read so the LeakDetector can
|
||||||
|
* verify no source date is on or after the target Monday.
|
||||||
|
*/
|
||||||
|
interface ForecastFeature
|
||||||
|
{
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature value at $targetMonday, or null when an upstream data
|
||||||
|
* row is missing. Caller is expected to drop the entire feature
|
||||||
|
* vector when any single feature is null.
|
||||||
|
*/
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every date this feature reads from any data source for a given
|
||||||
|
* target Monday. The LeakDetector requires every returned date to
|
||||||
|
* be strictly before $targetMonday.
|
||||||
|
*
|
||||||
|
* @return array<int, CarbonInterface>
|
||||||
|
*/
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array;
|
||||||
|
}
|
||||||
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal file
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Contracts;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\FeatureSpec;
|
||||||
|
use App\Services\Forecasting\WeeklyPrediction;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract every weekly forecaster must satisfy. The harness consumes
|
||||||
|
* this interface — naive baselines, ridge regression, and any future
|
||||||
|
* model all implement it.
|
||||||
|
*/
|
||||||
|
interface WeeklyForecastModel
|
||||||
|
{
|
||||||
|
public function featureSpec(): FeatureSpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Train on the supplied weeks. Implementations may store coefficients
|
||||||
|
* internally for the subsequent predict() calls.
|
||||||
|
*
|
||||||
|
* @param array<int, CarbonInterface> $trainingMondays
|
||||||
|
*/
|
||||||
|
public function train(array $trainingMondays): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predict ΔULSP for the week starting $targetMonday. Returned value
|
||||||
|
* is in pence × 100 (integer-ish, but typed float for fractional
|
||||||
|
* predictions).
|
||||||
|
*/
|
||||||
|
public function predict(CarbonInterface $targetMonday): WeeklyPrediction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coefficients in a JSON-serialisable form, or null for non-parametric
|
||||||
|
* models like the naive baseline.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function coefficients(): ?array;
|
||||||
|
}
|
||||||
45
app/Services/Forecasting/DutyChangeDetector.php
Normal file
45
app/Services/Forecasting/DutyChangeDetector.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags forecast weeks that fall within ±4 weeks of a known UK fuel
|
||||||
|
* duty change. Per the spec calibration override (n=1), the displayed
|
||||||
|
* confidence on flagged weeks is halved and the reasoning text says so.
|
||||||
|
*/
|
||||||
|
final class DutyChangeDetector
|
||||||
|
{
|
||||||
|
public const int FLAG_RADIUS_WEEKS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the target Monday is within ±4 weeks of any
|
||||||
|
* change in `weekly_pump_prices.ulsp_duty_pence`.
|
||||||
|
*/
|
||||||
|
public function isAdjacent(CarbonInterface $targetMonday): bool
|
||||||
|
{
|
||||||
|
$start = $targetMonday->copy()->subWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
|
||||||
|
$end = $targetMonday->copy()->addWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('weekly_pump_prices')
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->orderBy('date')
|
||||||
|
->get(['date', 'ulsp_duty_pence']);
|
||||||
|
|
||||||
|
if ($rows->count() < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($previous !== null && (int) $r->ulsp_duty_pence !== $previous) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$previous = (int) $r->ulsp_duty_pence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Services/Forecasting/FeatureSpec.php
Normal file
54
app/Services/Forecasting/FeatureSpec.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable list of features a model uses, plus a deterministic hash
|
||||||
|
* for audit linking on backtests.model_version.
|
||||||
|
*
|
||||||
|
* Two FeatureSpec instances with the same feature names + same model
|
||||||
|
* label produce the same hash, so retraining the same model
|
||||||
|
* configuration overwrites the same `backtests` row (via UNIQUE on
|
||||||
|
* model_version).
|
||||||
|
*/
|
||||||
|
final readonly class FeatureSpec
|
||||||
|
{
|
||||||
|
/** @param array<int, ForecastFeature> $features */
|
||||||
|
public function __construct(
|
||||||
|
public string $modelLabel,
|
||||||
|
public array $features,
|
||||||
|
) {
|
||||||
|
foreach ($features as $f) {
|
||||||
|
if (! $f instanceof ForecastFeature) {
|
||||||
|
throw new InvalidArgumentException('Every spec entry must implement ForecastFeature');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function names(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (ForecastFeature $f): string => $f->name(), $this->features);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modelVersion(): string
|
||||||
|
{
|
||||||
|
$names = $this->names();
|
||||||
|
sort($names);
|
||||||
|
$hash = substr(sha1(json_encode($names, JSON_THROW_ON_ERROR)), 0, 12);
|
||||||
|
|
||||||
|
return $this->modelLabel.'-'.$hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{model_label: string, features: array<int, string>} */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'model_label' => $this->modelLabel,
|
||||||
|
'features' => $this->names(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Services/Forecasting/Features/DeltaUlsdLag.php
Normal file
50
app/Services/Forecasting/Features/DeltaUlsdLag.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Features;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use App\Services\Forecasting\WeeklyPumpPriceLoader;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ΔULSD at lag L. Cross-fuel signal — diesel often leads/lags petrol
|
||||||
|
* during oil shocks. Same lag semantics as DeltaUlspLag.
|
||||||
|
*/
|
||||||
|
final class DeltaUlsdLag implements ForecastFeature
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WeeklyPumpPriceLoader $loader,
|
||||||
|
public readonly int $lag,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'delta_ulsd_lag_'.$this->lag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
[$newer, $older] = $this->dates($targetMonday);
|
||||||
|
$a = $this->loader->ulsdPence($newer->toDateString());
|
||||||
|
$b = $this->loader->ulsdPence($older->toDateString());
|
||||||
|
if ($a === null || $b === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($a - $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return $this->dates($targetMonday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: CarbonInterface, 1: CarbonInterface} */
|
||||||
|
private function dates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$targetMonday->copy()->subDays(7 * ($this->lag + 1)),
|
||||||
|
$targetMonday->copy()->subDays(7 * ($this->lag + 2)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Services/Forecasting/Features/DeltaUlspLag.php
Normal file
57
app/Services/Forecasting/Features/DeltaUlspLag.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Features;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use App\Services\Forecasting\WeeklyPumpPriceLoader;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ΔULSP at lag L: the change in petrol price that ended L weeks before
|
||||||
|
* the most recent observation, in pence × 100.
|
||||||
|
*
|
||||||
|
* lag=0 → ULSP[t-7d] − ULSP[t-14d] (1-week momentum)
|
||||||
|
* lag=1 → ULSP[t-14d] − ULSP[t-21d] (2-week momentum)
|
||||||
|
* lag=3 → ULSP[t-28d] − ULSP[t-35d] (4-week momentum)
|
||||||
|
*
|
||||||
|
* Source dates are always strictly before the target Monday — the
|
||||||
|
* earliest is target − 7×(lag+1), the older is target − 7×(lag+2).
|
||||||
|
*/
|
||||||
|
final class DeltaUlspLag implements ForecastFeature
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WeeklyPumpPriceLoader $loader,
|
||||||
|
public readonly int $lag,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'delta_ulsp_lag_'.$this->lag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
[$newer, $older] = $this->dates($targetMonday);
|
||||||
|
$a = $this->loader->ulspPence($newer->toDateString());
|
||||||
|
$b = $this->loader->ulspPence($older->toDateString());
|
||||||
|
if ($a === null || $b === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($a - $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return $this->dates($targetMonday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: CarbonInterface, 1: CarbonInterface} */
|
||||||
|
private function dates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$targetMonday->copy()->subDays(7 * ($this->lag + 1)),
|
||||||
|
$targetMonday->copy()->subDays(7 * ($this->lag + 2)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Services/Forecasting/Features/IsPreBankHoliday.php
Normal file
32
app/Services/Forecasting/Features/IsPreBankHoliday.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Features;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use App\Services\Forecasting\UkBankHolidays;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.0 if any UK bank holiday falls in the 7-day window starting at the
|
||||||
|
* target Monday; 0.0 otherwise.
|
||||||
|
*
|
||||||
|
* Captures pre-holiday demand spikes (Easter, summer, Christmas
|
||||||
|
* weekend). Pure calendar — no DB read, sourceDates is empty.
|
||||||
|
*/
|
||||||
|
final class IsPreBankHoliday implements ForecastFeature
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'is_pre_bank_holiday';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
return UkBankHolidays::holidayWithin($targetMonday, 7) ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Services/Forecasting/Features/UlspMinusMa8.php
Normal file
57
app/Services/Forecasting/Features/UlspMinusMa8.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Features;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use App\Services\Forecasting\WeeklyPumpPriceLoader;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean-reversion term: gap between the most recent observable ULSP
|
||||||
|
* (target − 7d) and its 8-week trailing mean (target − 7d through
|
||||||
|
* target − 56d, inclusive).
|
||||||
|
*
|
||||||
|
* Empirically this is the single most useful 1-week-ahead feature for
|
||||||
|
* UK pump prices — pump retailers tend to revert to their recent
|
||||||
|
* trailing mean, especially after sudden moves.
|
||||||
|
*/
|
||||||
|
final class UlspMinusMa8 implements ForecastFeature
|
||||||
|
{
|
||||||
|
private const int WINDOW_WEEKS = 8;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WeeklyPumpPriceLoader $loader,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'ulsp_minus_ma8';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
foreach ($this->sourceDates($targetMonday) as $d) {
|
||||||
|
$v = $this->loader->ulspPence($d->toDateString());
|
||||||
|
if ($v === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$values[] = (float) $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = $values[0];
|
||||||
|
$mean = array_sum($values) / count($values);
|
||||||
|
|
||||||
|
return $latest - $mean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
for ($w = 1; $w <= self::WINDOW_WEEKS; $w++) {
|
||||||
|
$dates[] = $targetMonday->copy()->subDays(7 * $w);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Services/Forecasting/Features/WeekOfYearTrig.php
Normal file
43
app/Services/Forecasting/Features/WeekOfYearTrig.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Features;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cyclic week-of-year encoding. Two instances expected, one for sin and
|
||||||
|
* one for cos. Together they let the linear model fit a smooth annual
|
||||||
|
* seasonal cycle without a 52-way one-hot expansion.
|
||||||
|
*
|
||||||
|
* This is a pure calendar feature — no DB read. sourceDates is empty,
|
||||||
|
* so the LeakDetector has nothing to validate against.
|
||||||
|
*/
|
||||||
|
final class WeekOfYearTrig implements ForecastFeature
|
||||||
|
{
|
||||||
|
public function __construct(public readonly string $component)
|
||||||
|
{
|
||||||
|
if (! in_array($component, ['sin', 'cos'], true)) {
|
||||||
|
throw new InvalidArgumentException('component must be "sin" or "cos"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'week_of_year_'.$this->component;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
$week = (int) $targetMonday->format('W'); // ISO week number 1..53
|
||||||
|
$angle = 2.0 * M_PI * $week / 52.0;
|
||||||
|
|
||||||
|
return $this->component === 'sin' ? sin($angle) : cos($angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Services/Forecasting/LeakDetector.php
Normal file
41
app/Services/Forecasting/LeakDetector.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural time-leak detector.
|
||||||
|
*
|
||||||
|
* For every (training week, feature) pair, verifies that every source
|
||||||
|
* date the feature reads is strictly before the target Monday. A
|
||||||
|
* source date on or after the target Monday is leakage and the
|
||||||
|
* backtest harness must refuse to run.
|
||||||
|
*
|
||||||
|
* This is the *primary* leak defence. The accuracy>75% smell test on
|
||||||
|
* the resulting backtest is a secondary check.
|
||||||
|
*/
|
||||||
|
final class LeakDetector
|
||||||
|
{
|
||||||
|
/** @param array<int, CarbonInterface> $trainingMondays */
|
||||||
|
public function validate(FeatureSpec $spec, array $trainingMondays): LeakReport
|
||||||
|
{
|
||||||
|
$leaks = [];
|
||||||
|
|
||||||
|
foreach ($trainingMondays as $target) {
|
||||||
|
foreach ($spec->features as $feature) {
|
||||||
|
foreach ($feature->sourceDates($target) as $source) {
|
||||||
|
if ($source->greaterThanOrEqualTo($target)) {
|
||||||
|
$leaks[] = [
|
||||||
|
'feature' => $feature->name(),
|
||||||
|
'target_monday' => $target->toDateString(),
|
||||||
|
'source_date' => $source->toDateString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LeakReport($leaks);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Services/Forecasting/LeakDetectorException.php
Normal file
19
app/Services/Forecasting/LeakDetectorException.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class LeakDetectorException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(public readonly LeakReport $report)
|
||||||
|
{
|
||||||
|
$count = count($report->leaks);
|
||||||
|
$first = $report->leaks[0] ?? null;
|
||||||
|
$sample = $first === null
|
||||||
|
? ''
|
||||||
|
: sprintf(' First: feature "%s" reads %s for target %s.', $first['feature'], $first['source_date'], $first['target_monday']);
|
||||||
|
|
||||||
|
parent::__construct(sprintf('Structural time leak detected in %d feature value(s).%s', $count, $sample));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Services/Forecasting/LeakReport.php
Normal file
20
app/Services/Forecasting/LeakReport.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a LeakDetector::validate() run.
|
||||||
|
*
|
||||||
|
* Each entry in $leaks is shape:
|
||||||
|
* { feature: string, target_monday: 'Y-m-d', source_date: 'Y-m-d' }
|
||||||
|
*/
|
||||||
|
final readonly class LeakReport
|
||||||
|
{
|
||||||
|
/** @param array<int, array{feature: string, target_monday: string, source_date: string}> $leaks */
|
||||||
|
public function __construct(public array $leaks) {}
|
||||||
|
|
||||||
|
public function hasLeaks(): bool
|
||||||
|
{
|
||||||
|
return $this->leaks !== [];
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Services/Forecasting/LinearAlgebra.php
Normal file
200
app/Services/Forecasting/LinearAlgebra.php
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-PHP linear algebra used by RidgeRegressionModel.
|
||||||
|
*
|
||||||
|
* Matrices are array<int, array<int, float>>. Vectors are array<int, float>.
|
||||||
|
* Sized for the v1 ridge model (435 × 8); Gauss–Jordan with partial
|
||||||
|
* pivoting is plenty for inverting the 8 × 8 normal-equation matrix.
|
||||||
|
*/
|
||||||
|
final class LinearAlgebra
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transpose. m is rows × cols → result is cols × rows.
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, float>> $m
|
||||||
|
* @return array<int, array<int, float>>
|
||||||
|
*/
|
||||||
|
public static function transpose(array $m): array
|
||||||
|
{
|
||||||
|
$rows = count($m);
|
||||||
|
if ($rows === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$cols = count($m[0]);
|
||||||
|
$out = array_fill(0, $cols, array_fill(0, $rows, 0.0));
|
||||||
|
for ($i = 0; $i < $rows; $i++) {
|
||||||
|
for ($j = 0; $j < $cols; $j++) {
|
||||||
|
$out[$j][$i] = $m[$i][$j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix multiply. a (r×k) * b (k×c) → r×c.
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, float>> $a
|
||||||
|
* @param array<int, array<int, float>> $b
|
||||||
|
* @return array<int, array<int, float>>
|
||||||
|
*/
|
||||||
|
public static function multiply(array $a, array $b): array
|
||||||
|
{
|
||||||
|
$r = count($a);
|
||||||
|
$k = count($a[0] ?? []);
|
||||||
|
$c = count($b[0] ?? []);
|
||||||
|
if (count($b) !== $k) {
|
||||||
|
throw new InvalidArgumentException('Matrix multiply dimension mismatch');
|
||||||
|
}
|
||||||
|
$out = array_fill(0, $r, array_fill(0, $c, 0.0));
|
||||||
|
for ($i = 0; $i < $r; $i++) {
|
||||||
|
for ($j = 0; $j < $c; $j++) {
|
||||||
|
$sum = 0.0;
|
||||||
|
for ($p = 0; $p < $k; $p++) {
|
||||||
|
$sum += $a[$i][$p] * $b[$p][$j];
|
||||||
|
}
|
||||||
|
$out[$i][$j] = $sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix × vector. a (r×k) * v (k) → r-vector.
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, float>> $a
|
||||||
|
* @param array<int, float> $v
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
public static function multiplyVector(array $a, array $v): array
|
||||||
|
{
|
||||||
|
$r = count($a);
|
||||||
|
$k = count($v);
|
||||||
|
if (count($a[0] ?? []) !== $k) {
|
||||||
|
throw new InvalidArgumentException('Matrix × vector dimension mismatch');
|
||||||
|
}
|
||||||
|
$out = array_fill(0, $r, 0.0);
|
||||||
|
for ($i = 0; $i < $r; $i++) {
|
||||||
|
$sum = 0.0;
|
||||||
|
for ($p = 0; $p < $k; $p++) {
|
||||||
|
$sum += $a[$i][$p] * $v[$p];
|
||||||
|
}
|
||||||
|
$out[$i] = $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity matrix of size n.
|
||||||
|
*
|
||||||
|
* @return array<int, array<int, float>>
|
||||||
|
*/
|
||||||
|
public static function identity(int $n): array
|
||||||
|
{
|
||||||
|
$out = array_fill(0, $n, array_fill(0, $n, 0.0));
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$out[$i][$i] = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve A x = b using Gauss–Jordan elimination with partial pivoting.
|
||||||
|
* A is square n×n. Returns x as an n-vector.
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, float>> $A
|
||||||
|
* @param array<int, float> $b
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
public static function solve(array $A, array $b): array
|
||||||
|
{
|
||||||
|
$n = count($A);
|
||||||
|
if (count($b) !== $n) {
|
||||||
|
throw new InvalidArgumentException('solve: RHS dimension mismatch');
|
||||||
|
}
|
||||||
|
// Build augmented matrix.
|
||||||
|
$aug = [];
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$aug[$i] = array_merge($A[$i], [$b[$i]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($col = 0; $col < $n; $col++) {
|
||||||
|
// Partial pivot: find row with largest |value| in this column.
|
||||||
|
$pivot = $col;
|
||||||
|
$best = abs($aug[$col][$col]);
|
||||||
|
for ($r = $col + 1; $r < $n; $r++) {
|
||||||
|
$v = abs($aug[$r][$col]);
|
||||||
|
if ($v > $best) {
|
||||||
|
$best = $v;
|
||||||
|
$pivot = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($best < 1e-12) {
|
||||||
|
throw new RuntimeException('solve: matrix is singular or near-singular');
|
||||||
|
}
|
||||||
|
if ($pivot !== $col) {
|
||||||
|
[$aug[$col], $aug[$pivot]] = [$aug[$pivot], $aug[$col]];
|
||||||
|
}
|
||||||
|
// Normalise pivot row.
|
||||||
|
$div = $aug[$col][$col];
|
||||||
|
for ($j = 0; $j <= $n; $j++) {
|
||||||
|
$aug[$col][$j] /= $div;
|
||||||
|
}
|
||||||
|
// Eliminate this column from every other row.
|
||||||
|
for ($r = 0; $r < $n; $r++) {
|
||||||
|
if ($r === $col) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$factor = $aug[$r][$col];
|
||||||
|
if ($factor === 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for ($j = 0; $j <= $n; $j++) {
|
||||||
|
$aug[$r][$j] -= $factor * $aug[$col][$j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = array_fill(0, $n, 0.0);
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$x[$i] = $aug[$i][$n];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ridge solve: β = (XᵀX + λI) ⁻¹ Xᵀy.
|
||||||
|
*
|
||||||
|
* λ is applied to all coefficients. Caller should standardise X and
|
||||||
|
* centre y before calling, then add intercept back externally — the
|
||||||
|
* intercept must NOT be regularised.
|
||||||
|
*
|
||||||
|
* @param array<int, array<int, float>> $X
|
||||||
|
* @param array<int, float> $y
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
public static function ridgeSolve(array $X, array $y, float $lambda): array
|
||||||
|
{
|
||||||
|
$Xt = self::transpose($X);
|
||||||
|
$XtX = self::multiply($Xt, $X);
|
||||||
|
|
||||||
|
$n = count($XtX);
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$XtX[$i][$i] += $lambda;
|
||||||
|
}
|
||||||
|
|
||||||
|
$Xty = self::multiplyVector($Xt, $y);
|
||||||
|
|
||||||
|
return self::solve($XtX, $Xty);
|
||||||
|
}
|
||||||
|
}
|
||||||
374
app/Services/Forecasting/LlmOverlayService.php
Normal file
374
app/Services/Forecasting/LlmOverlayService.php
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\BrentPrice;
|
||||||
|
use App\Models\LlmOverlay;
|
||||||
|
use App\Models\VolatilityRegime;
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 4 — daily news-aware overlay on the calibrated ridge forecast.
|
||||||
|
*
|
||||||
|
* Calls Anthropic Haiku with the web_search tool, then forces a
|
||||||
|
* submit_overlay tool call to get structured output. Cites events with
|
||||||
|
* URLs; URLs are verified before storing. Empty citations → rejection.
|
||||||
|
*
|
||||||
|
* Read-only with respect to the volatility flag — Layer 4 writes its
|
||||||
|
* `llm_overlays` row; Layer 5's hourly cron picks it up and decides
|
||||||
|
* whether to flip the regime.
|
||||||
|
*/
|
||||||
|
final class LlmOverlayService
|
||||||
|
{
|
||||||
|
private const string URL = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
|
private const int CONFIDENCE_CAP = 75;
|
||||||
|
|
||||||
|
private const int COOLDOWN_HOURS = 4;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiLogger $apiLogger,
|
||||||
|
private readonly WeeklyForecastService $weeklyForecast,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an overlay generation. $eventDriven=true respects the 4-hour
|
||||||
|
* cooldown; the daily 07:00 cron passes false to always run.
|
||||||
|
*/
|
||||||
|
public function run(bool $eventDriven = false): ?LlmOverlay
|
||||||
|
{
|
||||||
|
if ($this->apiKey() === null) {
|
||||||
|
Log::info('LlmOverlayService: no ANTHROPIC_API_KEY, skipping');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventDriven && $this->onCooldown()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$forecast = $this->weeklyForecast->currentForecast();
|
||||||
|
$context = $this->buildContext($forecast);
|
||||||
|
|
||||||
|
$rawResult = $this->callAnthropic($context);
|
||||||
|
if ($rawResult === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
|
||||||
|
if ($verifiedEvents === []) {
|
||||||
|
Log::warning('LlmOverlayService: no verified citations, rejecting overlay');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$confidence = max(0, min(self::CONFIDENCE_CAP, (int) ($rawResult['confidence'] ?? 0)));
|
||||||
|
$direction = $rawResult['direction'] ?? 'flat';
|
||||||
|
$agreesWithRidge = $direction === $this->ridgeDirection($forecast['predicted_direction']);
|
||||||
|
|
||||||
|
return LlmOverlay::query()->create([
|
||||||
|
'ran_at' => now(),
|
||||||
|
'forecast_for_week' => $this->upcomingMondayDateString(),
|
||||||
|
'direction' => $direction,
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'reasoning' => (string) ($rawResult['reasoning_short'] ?? ''),
|
||||||
|
'events_json' => $verifiedEvents,
|
||||||
|
'agrees_with_ridge' => $agreesWithRidge,
|
||||||
|
'major_impact_event' => (bool) ($rawResult['major_impact_event'] ?? false),
|
||||||
|
'volatility_flag_on' => VolatilityRegime::currentlyActive() !== null,
|
||||||
|
'search_used' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onCooldown(): bool
|
||||||
|
{
|
||||||
|
$latest = LlmOverlay::query()->orderByDesc('ran_at')->first();
|
||||||
|
|
||||||
|
return $latest !== null
|
||||||
|
&& $latest->ran_at->greaterThanOrEqualTo(now()->subHours(self::COOLDOWN_HOURS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function buildContext(array $forecast): array
|
||||||
|
{
|
||||||
|
$ulspWeekly = DB::table('weekly_pump_prices')
|
||||||
|
->orderByDesc('date')
|
||||||
|
->limit(8)
|
||||||
|
->get(['date', 'ulsp_pence'])
|
||||||
|
->reverse()
|
||||||
|
->map(fn ($r): array => ['date' => (string) $r->date, 'ulsp_pence' => round((int) $r->ulsp_pence / 100, 1)])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$brentRecent = BrentPrice::query()
|
||||||
|
->orderByDesc('date')
|
||||||
|
->limit(14)
|
||||||
|
->get(['date', 'price_usd'])
|
||||||
|
->reverse()
|
||||||
|
->map(fn (BrentPrice $r): array => ['date' => (string) $r->date->toDateString(), 'price_usd' => (float) $r->price_usd])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ulsp_recent_8_weeks' => $ulspWeekly,
|
||||||
|
'brent_recent_14_days' => $brentRecent,
|
||||||
|
'ridge_model_says' => [
|
||||||
|
'direction' => $forecast['predicted_direction'] ?? 'stable',
|
||||||
|
'confidence' => $forecast['confidence_score'] ?? 0,
|
||||||
|
'magnitude_pence' => $forecast['predicted_change_pence'] ?? 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
private function callAnthropic(array $context): ?array
|
||||||
|
{
|
||||||
|
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: web search loop
|
||||||
|
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||||
|
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post(self::URL, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||||
|
'max_tokens' => 1024,
|
||||||
|
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
|
||||||
|
'messages' => $messages,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('LlmOverlayService: search request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
|
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
|
||||||
|
|
||||||
|
// Phase 2: forced structured output
|
||||||
|
$submitResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post(self::URL, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||||
|
'max_tokens' => 512,
|
||||||
|
'tools' => [$this->submitOverlayTool()],
|
||||||
|
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||||
|
'messages' => $messages,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $submitResponse->successful()) {
|
||||||
|
Log::error('LlmOverlayService: submit request failed', ['status' => $submitResponse->status()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify each cited URL is reachable. Major news sites (Reuters, FT,
|
||||||
|
* Bloomberg, BBC...) often reject HEAD with 403 / 405 even though
|
||||||
|
* GET works fine. So: try HEAD first, then fall back to a 1-byte
|
||||||
|
* GET (Range header) when HEAD fails. Both must include a
|
||||||
|
* browser-shaped User-Agent or Cloudflare etc. block us as a bot.
|
||||||
|
*
|
||||||
|
* Every URL — verified or rejected — is logged at INFO/WARNING so
|
||||||
|
* operators can debug rejections from `storage/logs/laravel.log`
|
||||||
|
* without needing to capture the Anthropic response body.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $events
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function verifyCitedUrls(array $events): array
|
||||||
|
{
|
||||||
|
$verified = [];
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$url = (string) ($event['url'] ?? '');
|
||||||
|
if ($url === '') {
|
||||||
|
Log::warning('LlmOverlayService: dropping cited event with empty URL', [
|
||||||
|
'headline' => $event['headline'] ?? null,
|
||||||
|
'source' => $event['source'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$reachable, $diagnosis] = $this->urlReachable($url);
|
||||||
|
if ($reachable) {
|
||||||
|
Log::info('LlmOverlayService: URL verified', [
|
||||||
|
'url' => $url,
|
||||||
|
'via' => $diagnosis,
|
||||||
|
]);
|
||||||
|
$verified[] = $event;
|
||||||
|
} else {
|
||||||
|
Log::warning('LlmOverlayService: URL rejected', [
|
||||||
|
'url' => $url,
|
||||||
|
'reason' => $diagnosis,
|
||||||
|
'headline' => $event['headline'] ?? null,
|
||||||
|
'source' => $event['source'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
|
||||||
|
private function urlReachable(string $url): array
|
||||||
|
{
|
||||||
|
$headers = ['User-Agent' => self::VERIFICATION_USER_AGENT];
|
||||||
|
$headStatus = 'no-attempt';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$head = Http::timeout(5)
|
||||||
|
->withHeaders($headers)
|
||||||
|
->head($url);
|
||||||
|
$headStatus = 'HEAD='.$head->status();
|
||||||
|
if ($head->successful() || $head->redirect()) {
|
||||||
|
return [true, $headStatus];
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$headStatus = 'HEAD=exception('.class_basename($e).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$get = Http::timeout(8)
|
||||||
|
->withHeaders($headers + ['Range' => 'bytes=0-0'])
|
||||||
|
->get($url);
|
||||||
|
$getStatus = 'GET='.$get->status();
|
||||||
|
if ($get->successful() || $get->redirect()) {
|
||||||
|
return [true, $headStatus.' → '.$getStatus.' (fallback)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [false, $headStatus.' → '.$getStatus];
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [false, $headStatus.' → GET=exception('.class_basename($e).')'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ridgeDirection(string $publicDirection): string
|
||||||
|
{
|
||||||
|
return match ($publicDirection) {
|
||||||
|
'up' => 'rising',
|
||||||
|
'down' => 'falling',
|
||||||
|
default => 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upcomingMondayDateString(): string
|
||||||
|
{
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
$monday = $today->isMonday() ? $today : $today->copy()->next(CarbonInterface::MONDAY);
|
||||||
|
|
||||||
|
return $monday->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'x-api-key' => $this->apiKey(),
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiKey(): ?string
|
||||||
|
{
|
||||||
|
return config('services.anthropic.api_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prompt(array $context): string
|
||||||
|
{
|
||||||
|
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return <<<PROMPT
|
||||||
|
You are providing a daily news-aware overlay for a UK weekly pump-price forecast.
|
||||||
|
|
||||||
|
The calibrated ridge model has already produced a directional call from price history.
|
||||||
|
Your job is to search recent oil/fuel news and decide whether to AGREE or DISAGREE
|
||||||
|
— and most importantly, surface any major-impact event that the ridge model can't see
|
||||||
|
from price history alone.
|
||||||
|
|
||||||
|
Search recent news (last 48 hours) for:
|
||||||
|
- OPEC+ production decisions or unexpected announcements
|
||||||
|
- Geopolitical events affecting oil supply (sanctions, conflict, shipping disruption)
|
||||||
|
- Major refinery outages or pipeline incidents
|
||||||
|
- US/EU inventory reports that materially moved Brent
|
||||||
|
|
||||||
|
Context for this week:
|
||||||
|
$json
|
||||||
|
|
||||||
|
After searching, you will be asked to submit_overlay with direction, confidence
|
||||||
|
(capped at $this->confidenceCap), short reasoning, cited events with URLs,
|
||||||
|
agrees_with_ridge, and major_impact_event.
|
||||||
|
|
||||||
|
Citing events with REAL URLs is mandatory. An empty citation array will be
|
||||||
|
rejected and the overlay discarded.
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string $confidenceCap = '75';
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function submitOverlayTool(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'submit_overlay',
|
||||||
|
'description' => 'Submit the news-aware overlay for the upcoming weekly forecast.',
|
||||||
|
'input_schema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
||||||
|
'confidence' => ['type' => 'integer', 'minimum' => 0, 'maximum' => self::CONFIDENCE_CAP],
|
||||||
|
'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'],
|
||||||
|
'events_cited' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'headline' => ['type' => 'string'],
|
||||||
|
'source' => ['type' => 'string'],
|
||||||
|
'url' => ['type' => 'string'],
|
||||||
|
'impact' => ['type' => 'string', 'enum' => ['rising', 'falling', 'neutral']],
|
||||||
|
],
|
||||||
|
'required' => ['headline', 'source', 'url', 'impact'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'agrees_with_ridge' => ['type' => 'boolean'],
|
||||||
|
'major_impact_event' => ['type' => 'boolean'],
|
||||||
|
],
|
||||||
|
'required' => ['direction', 'confidence', 'reasoning_short', 'events_cited', 'agrees_with_ridge', 'major_impact_event'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $content
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function extractToolInput(array $content): ?array
|
||||||
|
{
|
||||||
|
$block = collect($content)->firstWhere('type', 'tool_use');
|
||||||
|
|
||||||
|
return $block['input'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Services/Forecasting/LocalSnapshotService.php
Normal file
147
app/Services/Forecasting/LocalSnapshotService.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Services\HaversineQuery;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 2 — descriptive snapshot of the present.
|
||||||
|
*
|
||||||
|
* Pure SQL aggregates against `station_prices_current` + Haversine on
|
||||||
|
* `stations.lat / lng`. No ML, no history, no surprises. Layer 2 never
|
||||||
|
* speaks about the future.
|
||||||
|
*
|
||||||
|
* Used by Phase 4's WeeklyForecastService to enrich the public payload
|
||||||
|
* with descriptive "your area" cards alongside the headline forecast.
|
||||||
|
*/
|
||||||
|
final class LocalSnapshotService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Snapshot for a coordinate (e.g. user's postcode-resolved lat/lng).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* national_avg_pence: ?float,
|
||||||
|
* local_avg_pence: ?float,
|
||||||
|
* local_minus_national_pence: ?float,
|
||||||
|
* cheapest_nearby: array<int, array{node_id: string, name: ?string, brand: ?string, price_pence: int, distance_km: float}>,
|
||||||
|
* supermarket_avg_pence: ?float,
|
||||||
|
* major_avg_pence: ?float,
|
||||||
|
* supermarket_gap_pence: ?float,
|
||||||
|
* stations_within_radius: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function snapshot(string $fuelType, float $lat, float $lng, int $radiusKm = 25): array
|
||||||
|
{
|
||||||
|
$nationalAvg = $this->nationalAverage($fuelType);
|
||||||
|
$localAvg = $this->localAverage($fuelType, $lat, $lng, 50);
|
||||||
|
$cheapest = $this->cheapestNearby($fuelType, $lat, $lng, $radiusKm, 5);
|
||||||
|
[$superAvg, $majorAvg] = $this->brandSplit($fuelType, $lat, $lng, $radiusKm);
|
||||||
|
$stationCount = $this->stationCountWithin($fuelType, $lat, $lng, $radiusKm);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'national_avg_pence' => $nationalAvg,
|
||||||
|
'local_avg_pence' => $localAvg,
|
||||||
|
'local_minus_national_pence' => $localAvg !== null && $nationalAvg !== null
|
||||||
|
? round($localAvg - $nationalAvg, 1)
|
||||||
|
: null,
|
||||||
|
'cheapest_nearby' => $cheapest,
|
||||||
|
'supermarket_avg_pence' => $superAvg,
|
||||||
|
'major_avg_pence' => $majorAvg,
|
||||||
|
'supermarket_gap_pence' => $superAvg !== null && $majorAvg !== null
|
||||||
|
? round($superAvg - $majorAvg, 1)
|
||||||
|
: null,
|
||||||
|
'stations_within_radius' => $stationCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nationalAverage(string $fuelType): ?float
|
||||||
|
{
|
||||||
|
$avg = DB::table('station_prices_current')
|
||||||
|
->where('fuel_type', $fuelType)
|
||||||
|
->avg('price_pence');
|
||||||
|
|
||||||
|
return $avg === null ? null : round((float) $avg / 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function localAverage(string $fuelType, float $lat, float $lng, int $km): ?float
|
||||||
|
{
|
||||||
|
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
|
||||||
|
|
||||||
|
$avg = DB::table('station_prices_current')
|
||||||
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices_current.fuel_type', $fuelType)
|
||||||
|
->whereRaw($within, $bindings)
|
||||||
|
->avg('station_prices_current.price_pence');
|
||||||
|
|
||||||
|
return $avg === null ? null : round((float) $avg / 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{node_id: string, name: ?string, brand: ?string, price_pence: int, distance_km: float}>
|
||||||
|
*/
|
||||||
|
private function cheapestNearby(string $fuelType, float $lat, float $lng, int $km, int $limit): array
|
||||||
|
{
|
||||||
|
[$distance, $distanceBindings] = HaversineQuery::distanceKm($lat, $lng);
|
||||||
|
[$within, $withinBindings] = HaversineQuery::withinKm($lat, $lng, $km);
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices_current')
|
||||||
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices_current.fuel_type', $fuelType)
|
||||||
|
->whereRaw($within, $withinBindings)
|
||||||
|
->selectRaw(
|
||||||
|
'stations.node_id, stations.trading_name as name, stations.brand_name as brand, '
|
||||||
|
.'station_prices_current.price_pence, '.$distance.' as distance_km',
|
||||||
|
$distanceBindings,
|
||||||
|
)
|
||||||
|
->orderBy('station_prices_current.price_pence')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $rows->map(fn ($r): array => [
|
||||||
|
'node_id' => (string) $r->node_id,
|
||||||
|
'name' => $r->name === null ? null : (string) $r->name,
|
||||||
|
'brand' => $r->brand === null ? null : (string) $r->brand,
|
||||||
|
'price_pence' => (int) $r->price_pence,
|
||||||
|
'distance_km' => round((float) $r->distance_km, 2),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: ?float, 1: ?float} [supermarket_avg, major_avg] */
|
||||||
|
private function brandSplit(string $fuelType, float $lat, float $lng, int $km): array
|
||||||
|
{
|
||||||
|
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices_current')
|
||||||
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices_current.fuel_type', $fuelType)
|
||||||
|
->whereRaw($within, $bindings)
|
||||||
|
->selectRaw('stations.is_supermarket, AVG(station_prices_current.price_pence) as avg_pence')
|
||||||
|
->groupBy('stations.is_supermarket')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$super = null;
|
||||||
|
$major = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$avg = round((float) $r->avg_pence / 100, 1);
|
||||||
|
if ((int) $r->is_supermarket === 1) {
|
||||||
|
$super = $avg;
|
||||||
|
} else {
|
||||||
|
$major = $avg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$super, $major];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stationCountWithin(string $fuelType, float $lat, float $lng, int $km): int
|
||||||
|
{
|
||||||
|
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
|
||||||
|
|
||||||
|
return DB::table('station_prices_current')
|
||||||
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices_current.fuel_type', $fuelType)
|
||||||
|
->whereRaw($within, $bindings)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Services/Forecasting/Models/NaiveZeroChangeModel.php
Normal file
39
app/Services/Forecasting/Models/NaiveZeroChangeModel.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Models;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
|
||||||
|
use App\Services\Forecasting\FeatureSpec;
|
||||||
|
use App\Services\Forecasting\WeeklyPrediction;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predicts ΔULSP[t+1] = 0 for every week. Direction = 'flat'.
|
||||||
|
*
|
||||||
|
* The floor any future model must beat. Per Alquist/Kilian, the
|
||||||
|
* no-change benchmark is hard to beat for short-horizon oil/fuel
|
||||||
|
* forecasts — if the ridge model can't beat this, the features are wrong.
|
||||||
|
*/
|
||||||
|
final class NaiveZeroChangeModel implements WeeklyForecastModel
|
||||||
|
{
|
||||||
|
public function featureSpec(): FeatureSpec
|
||||||
|
{
|
||||||
|
return new FeatureSpec(modelLabel: 'naive-zero', features: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function train(array $trainingMondays): void {}
|
||||||
|
|
||||||
|
public function predict(CarbonInterface $targetMonday): WeeklyPrediction
|
||||||
|
{
|
||||||
|
return new WeeklyPrediction(
|
||||||
|
targetMonday: $targetMonday,
|
||||||
|
magnitudePence: 0.0,
|
||||||
|
direction: 'flat',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function coefficients(): ?array
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Services/Forecasting/Models/RidgeRegressionModel.php
Normal file
201
app/Services/Forecasting/Models/RidgeRegressionModel.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Models;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
|
||||||
|
use App\Services\Forecasting\FeatureSpec;
|
||||||
|
use App\Services\Forecasting\LinearAlgebra;
|
||||||
|
use App\Services\Forecasting\WeeklyPrediction;
|
||||||
|
use App\Services\Forecasting\WeeklyPumpPriceLoader;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ridge regression on weekly pump prices.
|
||||||
|
*
|
||||||
|
* Target: ΔULSP[t+1] = ULSP[t+1] − ULSP[t], in pence × 100.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* - Build (X, y) from training Mondays. Skip any week where a feature
|
||||||
|
* value is null OR the actual ΔULSP cannot be computed.
|
||||||
|
* - Standardise X (z-score per column) and centre y. Keeps features
|
||||||
|
* on comparable scales so the L2 penalty is fair.
|
||||||
|
* - Solve β = (XᵀX + λI) ⁻¹ Xᵀy for the standardised problem.
|
||||||
|
* - Reconstruct intercept = mean(y) (since X is centred).
|
||||||
|
*
|
||||||
|
* Prediction:
|
||||||
|
* - Build feature vector at $targetMonday. If any feature returns
|
||||||
|
* null, predict 0 (treated as 'flat' downstream).
|
||||||
|
* - Standardise with the trained scaler, multiply by β, add intercept.
|
||||||
|
*
|
||||||
|
* Direction:
|
||||||
|
* - rising if magnitude > FLAT_THRESHOLD_PENCE_X100
|
||||||
|
* - falling if magnitude < −FLAT_THRESHOLD_PENCE_X100
|
||||||
|
* - flat otherwise
|
||||||
|
*/
|
||||||
|
final class RidgeRegressionModel implements WeeklyForecastModel
|
||||||
|
{
|
||||||
|
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
|
||||||
|
|
||||||
|
/** @var array<int, float>|null Coefficients on standardised features (no intercept). */
|
||||||
|
private ?array $beta = null;
|
||||||
|
|
||||||
|
private ?float $intercept = null;
|
||||||
|
|
||||||
|
/** @var array<int, float>|null per-feature mean used for standardisation */
|
||||||
|
private ?array $featureMeans = null;
|
||||||
|
|
||||||
|
/** @var array<int, float>|null per-feature std-dev used for standardisation */
|
||||||
|
private ?array $featureStdDevs = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly FeatureSpec $spec,
|
||||||
|
private readonly WeeklyPumpPriceLoader $loader,
|
||||||
|
public readonly float $lambda = 1.0,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function featureSpec(): FeatureSpec
|
||||||
|
{
|
||||||
|
return $this->spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function train(array $trainingMondays): void
|
||||||
|
{
|
||||||
|
$X = [];
|
||||||
|
$y = [];
|
||||||
|
|
||||||
|
foreach ($trainingMondays as $monday) {
|
||||||
|
$row = [];
|
||||||
|
$skip = false;
|
||||||
|
foreach ($this->spec->features as $feature) {
|
||||||
|
$v = $feature->valueFor($monday);
|
||||||
|
if ($v === null) {
|
||||||
|
$skip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$row[] = $v;
|
||||||
|
}
|
||||||
|
if ($skip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = $this->actualDeltaPence($monday);
|
||||||
|
if ($actual === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$X[] = $row;
|
||||||
|
$y[] = $actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($X) < count($this->spec->features) + 2) {
|
||||||
|
throw new RuntimeException('RidgeRegressionModel: insufficient training rows after dropping incomplete weeks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standardise X (z-score) and centre y.
|
||||||
|
$featureCount = count($X[0]);
|
||||||
|
$means = array_fill(0, $featureCount, 0.0);
|
||||||
|
$stds = array_fill(0, $featureCount, 0.0);
|
||||||
|
$n = count($X);
|
||||||
|
|
||||||
|
for ($j = 0; $j < $featureCount; $j++) {
|
||||||
|
$col = array_column($X, $j);
|
||||||
|
$means[$j] = array_sum($col) / $n;
|
||||||
|
$variance = 0.0;
|
||||||
|
foreach ($col as $v) {
|
||||||
|
$variance += ($v - $means[$j]) ** 2;
|
||||||
|
}
|
||||||
|
$variance /= $n;
|
||||||
|
$stds[$j] = sqrt($variance);
|
||||||
|
// Constant features get sd=1 so we don't divide by zero. Their
|
||||||
|
// contribution is then a constant absorbed by the intercept.
|
||||||
|
if ($stds[$j] < 1e-12) {
|
||||||
|
$stds[$j] = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Xstd = [];
|
||||||
|
foreach ($X as $row) {
|
||||||
|
$r = [];
|
||||||
|
for ($j = 0; $j < $featureCount; $j++) {
|
||||||
|
$r[] = ($row[$j] - $means[$j]) / $stds[$j];
|
||||||
|
}
|
||||||
|
$Xstd[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$yMean = array_sum($y) / $n;
|
||||||
|
$yCentred = array_map(fn (float $v): float => $v - $yMean, $y);
|
||||||
|
|
||||||
|
$this->beta = LinearAlgebra::ridgeSolve($Xstd, $yCentred, $this->lambda);
|
||||||
|
$this->intercept = $yMean;
|
||||||
|
$this->featureMeans = $means;
|
||||||
|
$this->featureStdDevs = $stds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function predict(CarbonInterface $targetMonday): WeeklyPrediction
|
||||||
|
{
|
||||||
|
if ($this->beta === null) {
|
||||||
|
throw new RuntimeException('RidgeRegressionModel: predict() called before train()');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [];
|
||||||
|
foreach ($this->spec->features as $feature) {
|
||||||
|
$v = $feature->valueFor($targetMonday);
|
||||||
|
if ($v === null) {
|
||||||
|
return new WeeklyPrediction($targetMonday, 0.0, 'flat');
|
||||||
|
}
|
||||||
|
$row[] = $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
$magnitude = $this->intercept;
|
||||||
|
for ($j = 0, $jc = count($row); $j < $jc; $j++) {
|
||||||
|
$z = ($row[$j] - $this->featureMeans[$j]) / $this->featureStdDevs[$j];
|
||||||
|
$magnitude += $z * $this->beta[$j];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeeklyPrediction($targetMonday, $magnitude, $this->classifyDirection($magnitude));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function coefficients(): ?array
|
||||||
|
{
|
||||||
|
if ($this->beta === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$named = [];
|
||||||
|
foreach ($this->spec->features as $i => $feature) {
|
||||||
|
$named[$feature->name()] = [
|
||||||
|
'beta_standardised' => $this->beta[$i],
|
||||||
|
'mean' => $this->featureMeans[$i],
|
||||||
|
'std_dev' => $this->featureStdDevs[$i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'intercept' => $this->intercept,
|
||||||
|
'lambda' => $this->lambda,
|
||||||
|
'features' => $named,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
$current = $this->loader->ulspPence($targetMonday->toDateString());
|
||||||
|
$previous = $this->loader->ulspPence($targetMonday->copy()->subDays(7)->toDateString());
|
||||||
|
|
||||||
|
if ($current === null || $previous === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($current - $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classifyDirection(float $magnitude): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$magnitude > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
|
||||||
|
$magnitude < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
|
||||||
|
default => 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Services/Forecasting/OutcomeResolver.php
Normal file
87
app/Services/Forecasting/OutcomeResolver.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\WeeklyForecast;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairs a `weekly_forecasts` row with the actual ULSP move once BEIS
|
||||||
|
* publishes the matching week. Writes idempotent rows to
|
||||||
|
* `forecast_outcomes` so trailing-13-week accuracy is honest, not
|
||||||
|
* inferred.
|
||||||
|
*/
|
||||||
|
final class OutcomeResolver
|
||||||
|
{
|
||||||
|
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0;
|
||||||
|
|
||||||
|
public function resolvePending(): int
|
||||||
|
{
|
||||||
|
$resolved = 0;
|
||||||
|
|
||||||
|
$existing = DB::table('forecast_outcomes')
|
||||||
|
->select(['forecast_for', 'model_version'])
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($r): array => [$r->forecast_for.'|'.$r->model_version => true])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$candidates = WeeklyForecast::query()
|
||||||
|
->where('forecast_for', '<=', now()->toDateString())
|
||||||
|
->orderBy('forecast_for')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($candidates as $forecast) {
|
||||||
|
$key = $forecast->forecast_for->toDateString().'|'.$forecast->model_version;
|
||||||
|
if (isset($existing[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualDelta = $this->actualDeltaPence($forecast->forecast_for->toDateString());
|
||||||
|
if ($actualDelta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualClass = $this->classifyDirection($actualDelta);
|
||||||
|
$absError = (int) round(abs($forecast->magnitude_pence - $actualDelta));
|
||||||
|
|
||||||
|
DB::table('forecast_outcomes')->insert([
|
||||||
|
'forecast_for' => $forecast->forecast_for->toDateString(),
|
||||||
|
'model_version' => $forecast->model_version,
|
||||||
|
'predicted_class' => $forecast->direction,
|
||||||
|
'actual_class' => $actualClass,
|
||||||
|
'correct' => $forecast->direction === $actualClass,
|
||||||
|
'abs_error_pence' => $absError,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actualDeltaPence(string $targetDate): ?float
|
||||||
|
{
|
||||||
|
$current = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', $targetDate)
|
||||||
|
->value('ulsp_pence');
|
||||||
|
$previous = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', date('Y-m-d', strtotime($targetDate.' -7 days')))
|
||||||
|
->value('ulsp_pence');
|
||||||
|
|
||||||
|
if ($current === null || $previous === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($current - $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classifyDirection(float $deltaPence): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
|
||||||
|
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
|
||||||
|
default => 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Services/Forecasting/ReasoningGenerator.php
Normal file
103
app/Services/Forecasting/ReasoningGenerator.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
use App\Services\Forecasting\Models\RidgeRegressionModel;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 6 honesty rule: the reasoning text only references features
|
||||||
|
* the model actually used, ranked by how much each contributed to
|
||||||
|
* this week's prediction.
|
||||||
|
*
|
||||||
|
* Contribution is the standardised (z-score × β) for each feature —
|
||||||
|
* the same number the ridge model summed to produce the prediction.
|
||||||
|
* That makes the explanation literally what the model did, not a
|
||||||
|
* narrative invented post-hoc.
|
||||||
|
*/
|
||||||
|
final class ReasoningGenerator
|
||||||
|
{
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private const array PHRASES = [
|
||||||
|
'delta_ulsp_lag_0' => "last week's pump price move",
|
||||||
|
'delta_ulsp_lag_1' => 'the pump price move two weeks ago',
|
||||||
|
'delta_ulsp_lag_3' => 'the pump price move four weeks ago',
|
||||||
|
'delta_ulsd_lag_0' => "last week's diesel move",
|
||||||
|
'ulsp_minus_ma8' => "the gap between this week's pump price and its 8-week average",
|
||||||
|
'week_of_year_sin' => 'the seasonal pattern',
|
||||||
|
'week_of_year_cos' => 'the seasonal pattern',
|
||||||
|
'is_pre_bank_holiday' => 'an upcoming bank holiday',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ForecastFeature> $features
|
||||||
|
*/
|
||||||
|
public function generate(
|
||||||
|
RidgeRegressionModel $model,
|
||||||
|
WeeklyPrediction $prediction,
|
||||||
|
array $features,
|
||||||
|
CarbonInterface $targetMonday,
|
||||||
|
int $confidence,
|
||||||
|
bool $flaggedDutyChange,
|
||||||
|
?float $trailingHitRate,
|
||||||
|
): string {
|
||||||
|
if ($confidence < 40) {
|
||||||
|
return 'Not enough signal in the historical pattern to call this week — staying silent.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$coeffs = $model->coefficients() ?? [];
|
||||||
|
$features_meta = $coeffs['features'] ?? [];
|
||||||
|
|
||||||
|
$contributions = [];
|
||||||
|
foreach ($features as $f) {
|
||||||
|
$name = $f->name();
|
||||||
|
$meta = $features_meta[$name] ?? null;
|
||||||
|
if ($meta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $f->valueFor($targetMonday);
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$z = ($value - $meta['mean']) / ($meta['std_dev'] ?: 1.0);
|
||||||
|
$contributions[$name] = $z * $meta['beta_standardised'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$headline = $this->headline($prediction);
|
||||||
|
$driver = $this->dominantFeatureSentence($contributions);
|
||||||
|
$duty = $flaggedDutyChange
|
||||||
|
? ' Recent fuel duty change may skew accuracy for the next several weeks.'
|
||||||
|
: '';
|
||||||
|
$accuracy = $trailingHitRate !== null
|
||||||
|
? sprintf(' Last 13 weeks: %d%% hit rate.', (int) round($trailingHitRate * 100))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return $headline.' '.$driver.$duty.$accuracy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function headline(WeeklyPrediction $prediction): string
|
||||||
|
{
|
||||||
|
$absP = round(abs($prediction->magnitudePence) / 100, 1);
|
||||||
|
|
||||||
|
return match ($prediction->direction) {
|
||||||
|
'rising' => sprintf('Model expects pump prices to rise by ~%sp/L next week.', number_format($absP, 1)),
|
||||||
|
'falling' => sprintf('Model expects pump prices to fall by ~%sp/L next week.', number_format($absP, 1)),
|
||||||
|
default => 'Pump prices are likely flat next week.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, float> $contributions */
|
||||||
|
private function dominantFeatureSentence(array $contributions): string
|
||||||
|
{
|
||||||
|
if ($contributions === []) {
|
||||||
|
return 'Drawn from the full feature set with no single dominant signal.';
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($contributions, fn (float $a, float $b): int => abs($b) <=> abs($a));
|
||||||
|
$topName = array_key_first($contributions);
|
||||||
|
$phrase = self::PHRASES[$topName] ?? $topName;
|
||||||
|
|
||||||
|
return sprintf('Driver: %s.', $phrase);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Services/Forecasting/UkBankHolidays.php
Normal file
146
app/Services/Forecasting/UkBankHolidays.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UK England-and-Wales bank holiday calendar.
|
||||||
|
*
|
||||||
|
* Computed deterministically from year (no external dependency, no
|
||||||
|
* hardcoded list to maintain).
|
||||||
|
*
|
||||||
|
* Includes the eight statutory holidays:
|
||||||
|
* New Year's Day, Good Friday, Easter Monday,
|
||||||
|
* Early May Bank Holiday, Spring Bank Holiday, Summer Bank Holiday,
|
||||||
|
* Christmas Day, Boxing Day
|
||||||
|
*
|
||||||
|
* Substitution rules: when a fixed-date holiday falls on a weekend,
|
||||||
|
* it's observed on the next non-holiday weekday (cascades for
|
||||||
|
* Christmas+Boxing landing on Sat+Sun).
|
||||||
|
*/
|
||||||
|
final class UkBankHolidays
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Sorted list of bank holiday dates for a year, after substitution.
|
||||||
|
*
|
||||||
|
* @return array<int, Carbon>
|
||||||
|
*/
|
||||||
|
public static function forYear(int $year): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
|
||||||
|
// Easter-anchored
|
||||||
|
[$em, $ed] = self::easter($year);
|
||||||
|
$easter = Carbon::create($year, $em, $ed);
|
||||||
|
$dates[] = $easter->copy()->subDays(2); // Good Friday
|
||||||
|
$dates[] = $easter->copy()->addDay(); // Easter Monday
|
||||||
|
|
||||||
|
// Floating Mondays
|
||||||
|
$dates[] = self::firstMondayOf($year, 5);
|
||||||
|
$dates[] = self::lastMondayOf($year, 5);
|
||||||
|
$dates[] = self::lastMondayOf($year, 8);
|
||||||
|
|
||||||
|
// Fixed dates with substitution
|
||||||
|
$dates[] = self::substituteForward(Carbon::create($year, 1, 1), $dates);
|
||||||
|
$christmas = self::substituteForward(Carbon::create($year, 12, 25), $dates);
|
||||||
|
$dates[] = $christmas;
|
||||||
|
$boxing = self::substituteForward(Carbon::create($year, 12, 26), $dates);
|
||||||
|
$dates[] = $boxing;
|
||||||
|
|
||||||
|
usort($dates, fn (CarbonInterface $a, CarbonInterface $b): int => $a->getTimestamp() <=> $b->getTimestamp());
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is there a UK bank holiday in [$from, $from + $daysAhead - 1]?
|
||||||
|
*/
|
||||||
|
public static function holidayWithin(CarbonInterface $from, int $daysAhead): bool
|
||||||
|
{
|
||||||
|
$end = $from->copy()->addDays($daysAhead - 1);
|
||||||
|
$years = array_unique([(int) $from->format('Y'), (int) $end->format('Y')]);
|
||||||
|
|
||||||
|
foreach ($years as $year) {
|
||||||
|
foreach (self::forYear($year) as $holiday) {
|
||||||
|
if ($holiday->betweenIncluded($from, $end)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous Gregorian algorithm for Easter Sunday.
|
||||||
|
*
|
||||||
|
* @return array{0: int, 1: int} [month, day]
|
||||||
|
*/
|
||||||
|
private static function easter(int $year): array
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return [$month, $day];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function firstMondayOf(int $year, int $month): Carbon
|
||||||
|
{
|
||||||
|
$d = Carbon::create($year, $month, 1);
|
||||||
|
while ($d->dayOfWeek !== Carbon::MONDAY) {
|
||||||
|
$d->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lastMondayOf(int $year, int $month): Carbon
|
||||||
|
{
|
||||||
|
$d = Carbon::create($year, $month, 1)->endOfMonth()->startOfDay();
|
||||||
|
while ($d->dayOfWeek !== Carbon::MONDAY) {
|
||||||
|
$d->subDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If $candidate falls on a weekend or collides with an already-claimed
|
||||||
|
* date, return the next non-weekend non-claimed date. Christmas/Boxing
|
||||||
|
* cascade is handled because we pass in the running list.
|
||||||
|
*
|
||||||
|
* @param array<int, CarbonInterface> $taken
|
||||||
|
*/
|
||||||
|
private static function substituteForward(Carbon $candidate, array $taken): Carbon
|
||||||
|
{
|
||||||
|
$d = $candidate->copy();
|
||||||
|
while (true) {
|
||||||
|
$isWeekend = in_array($d->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY], true);
|
||||||
|
$isTaken = false;
|
||||||
|
foreach ($taken as $t) {
|
||||||
|
if ($t->isSameDay($d)) {
|
||||||
|
$isTaken = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $isWeekend && ! $isTaken) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
$d->addDay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
app/Services/Forecasting/VolatilityRegimeService.php
Normal file
209
app/Services/Forecasting/VolatilityRegimeService.php
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\BrentPrice;
|
||||||
|
use App\Models\LlmOverlay;
|
||||||
|
use App\Models\VolatilityRegime;
|
||||||
|
use App\Models\WatchedEvent;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 5 — sole owner of `volatility_regimes.active`. Hourly cron.
|
||||||
|
*
|
||||||
|
* OR-combines four triggers:
|
||||||
|
* 1. Brent close-to-close move > 3% (FRED `DCOILBRENTEU`).
|
||||||
|
* 2. Most recent `llm_overlays.major_impact_event = true` AND at
|
||||||
|
* least one verified URL.
|
||||||
|
* 3. `station_prices` daily churn > 1.5× 30-day baseline. Gated
|
||||||
|
* until ≥ 180 days of polling — toggleable via config.
|
||||||
|
* 4. `watched_events` row covering today.
|
||||||
|
*
|
||||||
|
* When the flag flips ON, an event-driven LLM refresh is queued
|
||||||
|
* (Layer 4 enforces its own 4h cooldown). When OFF, the row is
|
||||||
|
* closed with `flipped_off_at`.
|
||||||
|
*/
|
||||||
|
final class VolatilityRegimeService
|
||||||
|
{
|
||||||
|
private const float BRENT_MOVE_PCT = 3.0;
|
||||||
|
|
||||||
|
private const float STATION_CHURN_RATIO = 1.5;
|
||||||
|
|
||||||
|
private const int STATION_CHURN_MIN_POLLING_DAYS = 180;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LlmOverlayService $llmOverlay,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function evaluate(): ?VolatilityRegime
|
||||||
|
{
|
||||||
|
$trigger = $this->detectTrigger();
|
||||||
|
$current = VolatilityRegime::currentlyActive();
|
||||||
|
|
||||||
|
if ($trigger !== null && $current === null) {
|
||||||
|
$row = $this->flipOn($trigger);
|
||||||
|
$this->llmOverlay->run(eventDriven: true);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($trigger === null && $current !== null) {
|
||||||
|
$this->flipOff($current);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{type: string, detail: string}|null */
|
||||||
|
private function detectTrigger(): ?array
|
||||||
|
{
|
||||||
|
return $this->brentMoveTrigger()
|
||||||
|
?? $this->llmEventTrigger()
|
||||||
|
?? $this->stationChurnTrigger()
|
||||||
|
?? $this->watchedEventTrigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{type: string, detail: string}|null */
|
||||||
|
private function brentMoveTrigger(): ?array
|
||||||
|
{
|
||||||
|
$rows = BrentPrice::query()
|
||||||
|
->orderByDesc('date')
|
||||||
|
->limit(2)
|
||||||
|
->get(['date', 'price_usd']);
|
||||||
|
|
||||||
|
if ($rows->count() < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = (float) $rows[0]->price_usd;
|
||||||
|
$prior = (float) $rows[1]->price_usd;
|
||||||
|
if ($prior === 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pctMove = abs(($latest - $prior) / $prior) * 100;
|
||||||
|
if ($pctMove <= self::BRENT_MOVE_PCT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = $latest > $prior ? '+' : '-';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'brent_move',
|
||||||
|
'detail' => sprintf('Brent %s%.2f%% (%s → %s)', $direction, $pctMove, $rows[1]->date->toDateString(), $rows[0]->date->toDateString()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{type: string, detail: string}|null */
|
||||||
|
private function llmEventTrigger(): ?array
|
||||||
|
{
|
||||||
|
$latest = LlmOverlay::query()->orderByDesc('ran_at')->first();
|
||||||
|
|
||||||
|
if ($latest === null || ! $latest->major_impact_event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasVerifiedUrl = collect((array) $latest->events_json)
|
||||||
|
->contains(fn ($e): bool => is_array($e) && ! empty($e['url']));
|
||||||
|
|
||||||
|
if (! $hasVerifiedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headline = collect((array) $latest->events_json)->pluck('headline')->filter()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'llm_event',
|
||||||
|
'detail' => sprintf('LLM major impact: %s', $headline ?? 'unspecified'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{type: string, detail: string}|null */
|
||||||
|
private function stationChurnTrigger(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->stationChurnEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldest = DB::table('station_prices')->min('price_effective_at');
|
||||||
|
if ($oldest === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pollingDays = (int) abs(now()->diffInDays($oldest));
|
||||||
|
if ($pollingDays < self::STATION_CHURN_MIN_POLLING_DAYS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$last24h = (int) DB::table('station_prices')
|
||||||
|
->where('price_effective_at', '>=', now()->subDay())
|
||||||
|
->distinct('station_id')
|
||||||
|
->count('station_id');
|
||||||
|
|
||||||
|
$baseline = (int) DB::table('station_prices')
|
||||||
|
->where('price_effective_at', '>=', now()->subDays(30))
|
||||||
|
->where('price_effective_at', '<', now()->subDay())
|
||||||
|
->distinct('station_id')
|
||||||
|
->count('station_id');
|
||||||
|
|
||||||
|
if ($baseline === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dailyBaseline = $baseline / 29; // 29 days of history before yesterday
|
||||||
|
if ($last24h <= $dailyBaseline * self::STATION_CHURN_RATIO) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'station_churn',
|
||||||
|
'detail' => sprintf('Station churn %d/24h vs %.1f baseline (%.2fx)', $last24h, $dailyBaseline, $last24h / $dailyBaseline),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{type: string, detail: string}|null */
|
||||||
|
private function watchedEventTrigger(): ?array
|
||||||
|
{
|
||||||
|
$row = WatchedEvent::query()
|
||||||
|
->where('starts_at', '<=', now())
|
||||||
|
->where('ends_at', '>=', now())
|
||||||
|
->orderBy('starts_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'manual',
|
||||||
|
'detail' => sprintf('Watched event: %s', $row->label),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stationChurnEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('services.forecasting.station_churn_enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{type: string, detail: string} $trigger */
|
||||||
|
private function flipOn(array $trigger): VolatilityRegime
|
||||||
|
{
|
||||||
|
return VolatilityRegime::query()->create([
|
||||||
|
'flipped_on_at' => now(),
|
||||||
|
'flipped_off_at' => null,
|
||||||
|
'trigger' => $trigger['type'],
|
||||||
|
'trigger_detail' => $trigger['detail'],
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flipOff(VolatilityRegime $row): void
|
||||||
|
{
|
||||||
|
$row->update([
|
||||||
|
'flipped_off_at' => now(),
|
||||||
|
'active' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/Services/Forecasting/WeeklyForecastService.php
Normal file
307
app/Services/Forecasting/WeeklyForecastService.php
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use App\Services\Forecasting\Contracts\ForecastFeature;
|
||||||
|
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\Models\RidgeRegressionModel;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 1 — orchestrates the ridge model end-to-end:
|
||||||
|
*
|
||||||
|
* 1. Builds the canonical v1 feature spec (8 features).
|
||||||
|
* 2. Trains the ridge model on every available BEIS Monday.
|
||||||
|
* 3. Predicts for the upcoming Monday.
|
||||||
|
* 4. Looks up the latest matching backtest for calibrated confidence.
|
||||||
|
* 5. Returns a flat array keyed for the existing public JSON contract.
|
||||||
|
*
|
||||||
|
* Trained-model state is cached for 1 hour (key includes model_version)
|
||||||
|
* so repeated request hits don't retrain. A new BEIS week or a feature
|
||||||
|
* spec change rolls model_version, busting the cache automatically.
|
||||||
|
*/
|
||||||
|
final class WeeklyForecastService
|
||||||
|
{
|
||||||
|
private const float DEFAULT_LAMBDA = 1.0;
|
||||||
|
|
||||||
|
public function currentForecast(): array
|
||||||
|
{
|
||||||
|
$loader = new WeeklyPumpPriceLoader;
|
||||||
|
$features = $this->buildFeatures($loader);
|
||||||
|
$spec = new FeatureSpec('ridge-v1', $features);
|
||||||
|
|
||||||
|
$cacheKey = 'forecast:current:'.$spec->modelVersion();
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, 3600, function () use ($loader, $spec, $features): array {
|
||||||
|
$model = new RidgeRegressionModel($spec, $loader, self::DEFAULT_LAMBDA);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$model->train($this->collectTrainingMondays($loader));
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return $this->insufficientDataPayload($spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetMonday = $this->upcomingMonday();
|
||||||
|
$prediction = $model->predict($targetMonday);
|
||||||
|
|
||||||
|
$rawConfidence = $this->confidenceFromCalibration($spec, $prediction);
|
||||||
|
$flaggedDutyChange = (new DutyChangeDetector)->isAdjacent($targetMonday);
|
||||||
|
$confidence = $flaggedDutyChange ? (int) round($rawConfidence / 2) : $rawConfidence;
|
||||||
|
|
||||||
|
$directionPublic = $this->mapDirection($prediction->direction);
|
||||||
|
$action = $this->mapAction($directionPublic, $confidence);
|
||||||
|
|
||||||
|
$trailingHitRate = (new AccuracyHistory)->trailingHitRate($spec->modelVersion());
|
||||||
|
|
||||||
|
$reasoning = (new ReasoningGenerator)->generate(
|
||||||
|
$model,
|
||||||
|
$prediction,
|
||||||
|
$features,
|
||||||
|
$targetMonday,
|
||||||
|
$confidence,
|
||||||
|
$flaggedDutyChange,
|
||||||
|
$trailingHitRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->persistForecast($spec, $targetMonday, $prediction, $confidence, $flaggedDutyChange, $reasoning);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'fuel_type' => 'e10',
|
||||||
|
'current_avg' => $this->nationalCurrentAverage(),
|
||||||
|
'predicted_direction' => $directionPublic,
|
||||||
|
'predicted_change_pence' => round($prediction->magnitudePence / 100, 1),
|
||||||
|
'confidence_score' => $confidence,
|
||||||
|
'confidence_label' => $this->confidenceLabel($confidence),
|
||||||
|
'action' => $action,
|
||||||
|
'reasoning' => $reasoning,
|
||||||
|
'prediction_horizon_days' => 7,
|
||||||
|
'region_key' => 'national',
|
||||||
|
'methodology' => 'ridge_regression_v1',
|
||||||
|
'model_version' => $spec->modelVersion(),
|
||||||
|
'flagged_duty_change' => $flaggedDutyChange,
|
||||||
|
'trailing_hit_rate' => $trailingHitRate,
|
||||||
|
'weekly_summary' => $this->weeklySummary($loader),
|
||||||
|
'signals' => $this->describeSignals($model, $prediction),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canonical v1 feature list. Centralised here so
|
||||||
|
* WeeklyForecastService and any retraining command share the same
|
||||||
|
* spec.
|
||||||
|
*
|
||||||
|
* @return array<int, ForecastFeature>
|
||||||
|
*/
|
||||||
|
private function buildFeatures(WeeklyPumpPriceLoader $loader): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new DeltaUlspLag($loader, lag: 0),
|
||||||
|
new DeltaUlspLag($loader, lag: 1),
|
||||||
|
new DeltaUlspLag($loader, lag: 3),
|
||||||
|
new DeltaUlsdLag($loader, lag: 0),
|
||||||
|
new UlspMinusMa8($loader),
|
||||||
|
new WeekOfYearTrig('sin'),
|
||||||
|
new WeekOfYearTrig('cos'),
|
||||||
|
new IsPreBankHoliday,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, CarbonInterface> */
|
||||||
|
private function collectTrainingMondays(WeeklyPumpPriceLoader $loader): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $d): CarbonInterface => Carbon::parse($d), $loader->allDates());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upcomingMonday(): CarbonInterface
|
||||||
|
{
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
|
||||||
|
return $today->isMonday() ? $today : $today->copy()->next(Carbon::MONDAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function confidenceFromCalibration(FeatureSpec $spec, WeeklyPrediction $prediction): int
|
||||||
|
{
|
||||||
|
$latest = Backtest::query()
|
||||||
|
->where('model_version', $spec->modelVersion())
|
||||||
|
->orderByDesc('ran_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latest === null) {
|
||||||
|
return 0; // no backtest yet → low (gate 2 will force no_signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = (array) ($latest->calibration_table ?? []);
|
||||||
|
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
|
||||||
|
$hitRate = $table[$bin] ?? null;
|
||||||
|
|
||||||
|
if ($hitRate === null) {
|
||||||
|
return (int) round((float) ($latest->directional_accuracy ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(((float) $hitRate) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bucketForMagnitude(float $magnitudePence): string
|
||||||
|
{
|
||||||
|
$abs = abs($magnitudePence);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$abs < 50.0 => '0.0-0.5p',
|
||||||
|
$abs < 100.0 => '0.5-1.0p',
|
||||||
|
default => '1.0p+',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapDirection(string $modelDirection): string
|
||||||
|
{
|
||||||
|
return match ($modelDirection) {
|
||||||
|
'rising' => 'up',
|
||||||
|
'falling' => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapAction(string $publicDirection, int $confidence): string
|
||||||
|
{
|
||||||
|
if ($publicDirection === 'stable' || $confidence < 40) {
|
||||||
|
return 'no_signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $publicDirection === 'up' ? 'fill_now' : 'wait';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function confidenceLabel(int $confidence): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$confidence >= 70 => 'high',
|
||||||
|
$confidence >= 40 => 'medium',
|
||||||
|
default => 'low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful payload when the model can't train (e.g. fresh install,
|
||||||
|
* not enough BEIS rows yet). Honest about not-knowing — verdict is
|
||||||
|
* no_signal, confidence 0, reasoning explains why.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function insufficientDataPayload(FeatureSpec $spec): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fuel_type' => 'e10',
|
||||||
|
'current_avg' => $this->nationalCurrentAverage(),
|
||||||
|
'predicted_direction' => 'stable',
|
||||||
|
'predicted_change_pence' => 0.0,
|
||||||
|
'confidence_score' => 0,
|
||||||
|
'confidence_label' => 'low',
|
||||||
|
'action' => 'no_signal',
|
||||||
|
'reasoning' => 'Not enough historical BEIS data yet to train the forecast model — staying silent until the series fills in.',
|
||||||
|
'prediction_horizon_days' => 7,
|
||||||
|
'region_key' => 'national',
|
||||||
|
'methodology' => 'ridge_regression_v1',
|
||||||
|
'model_version' => $spec->modelVersion(),
|
||||||
|
'weekly_summary' => [
|
||||||
|
'latest_publication_date' => null,
|
||||||
|
'latest_avg_pence' => null,
|
||||||
|
'prior_avg_pence' => null,
|
||||||
|
'latest_change_pence' => null,
|
||||||
|
],
|
||||||
|
'signals' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nationalCurrentAverage(): float
|
||||||
|
{
|
||||||
|
$avg = DB::table('station_prices_current')
|
||||||
|
->where('fuel_type', 'e10')
|
||||||
|
->avg('price_pence');
|
||||||
|
|
||||||
|
return $avg === null ? 0.0 : round((float) $avg / 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function weeklySummary(WeeklyPumpPriceLoader $loader): array
|
||||||
|
{
|
||||||
|
$dates = $loader->allDates();
|
||||||
|
$latest = end($dates) ?: null;
|
||||||
|
$prior = $latest === null ? null : ($dates[count($dates) - 2] ?? null);
|
||||||
|
|
||||||
|
$todayPence = $latest === null ? null : $loader->ulspPence($latest);
|
||||||
|
$priorPence = $prior === null ? null : $loader->ulspPence($prior);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'latest_publication_date' => $latest,
|
||||||
|
'latest_avg_pence' => $todayPence === null ? null : round($todayPence / 100, 1),
|
||||||
|
'prior_avg_pence' => $priorPence === null ? null : round($priorPence / 100, 1),
|
||||||
|
'latest_change_pence' => $todayPence !== null && $priorPence !== null
|
||||||
|
? round(($todayPence - $priorPence) / 100, 1)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compat 'signals' key. Now describes which features carried
|
||||||
|
* the most weight in this week's prediction (z-score × β contribution).
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function describeSignals(RidgeRegressionModel $model, WeeklyPrediction $prediction): array
|
||||||
|
{
|
||||||
|
$coeffs = $model->coefficients();
|
||||||
|
if ($coeffs === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ridge_v1' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'direction' => $prediction->direction,
|
||||||
|
'magnitude_pence' => round($prediction->magnitudePence / 100, 2),
|
||||||
|
'feature_count' => count($coeffs['features'] ?? []),
|
||||||
|
'lambda' => $coeffs['lambda'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the forecast row so Phase 6's outcome resolver can pair
|
||||||
|
* it with the actual ULSP when the next BEIS week lands.
|
||||||
|
* Idempotent on (forecast_for, model_version) via UPSERT.
|
||||||
|
*/
|
||||||
|
private function persistForecast(
|
||||||
|
FeatureSpec $spec,
|
||||||
|
CarbonInterface $targetMonday,
|
||||||
|
WeeklyPrediction $prediction,
|
||||||
|
int $confidence,
|
||||||
|
bool $flaggedDutyChange,
|
||||||
|
string $reasoning,
|
||||||
|
): void {
|
||||||
|
DB::table('weekly_forecasts')->upsert(
|
||||||
|
[[
|
||||||
|
'forecast_for' => $targetMonday->toDateString(),
|
||||||
|
'model_version' => $spec->modelVersion(),
|
||||||
|
'direction' => $prediction->direction,
|
||||||
|
'magnitude_pence' => (int) round($prediction->magnitudePence),
|
||||||
|
'ridge_confidence' => max(0, min(100, $confidence)),
|
||||||
|
'flagged_duty_change' => $flaggedDutyChange,
|
||||||
|
'reasoning' => $reasoning,
|
||||||
|
'generated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]],
|
||||||
|
['forecast_for', 'model_version'],
|
||||||
|
['direction', 'magnitude_pence', 'ridge_confidence', 'flagged_duty_change', 'reasoning', 'generated_at', 'updated_at'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Services/Forecasting/WeeklyPrediction.php
Normal file
20
app/Services/Forecasting/WeeklyPrediction.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The output of WeeklyForecastModel::predict().
|
||||||
|
*
|
||||||
|
* direction is derived from magnitudePence vs FLAT_THRESHOLD by the
|
||||||
|
* model itself, so the harness never re-derives it.
|
||||||
|
*/
|
||||||
|
final readonly class WeeklyPrediction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public CarbonInterface $targetMonday,
|
||||||
|
public float $magnitudePence,
|
||||||
|
public string $direction,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
58
app/Services/Forecasting/WeeklyPumpPriceLoader.php
Normal file
58
app/Services/Forecasting/WeeklyPumpPriceLoader.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads `weekly_pump_prices` once into an in-memory map keyed by date.
|
||||||
|
*
|
||||||
|
* Used by features and the ridge model — avoids one SELECT per
|
||||||
|
* (week × feature) lookup. Lazy: nothing loads until first query.
|
||||||
|
*/
|
||||||
|
final class WeeklyPumpPriceLoader
|
||||||
|
{
|
||||||
|
/** @var array<string, object{date: string, ulsp_pence: int, ulsd_pence: int}>|null */
|
||||||
|
private ?array $byDate = null;
|
||||||
|
|
||||||
|
public function ulspPence(string $date): ?int
|
||||||
|
{
|
||||||
|
$row = $this->byDate()[$date] ?? null;
|
||||||
|
|
||||||
|
return $row === null ? null : (int) $row->ulsp_pence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ulsdPence(string $date): ?int
|
||||||
|
{
|
||||||
|
$row = $this->byDate()[$date] ?? null;
|
||||||
|
|
||||||
|
return $row === null ? null : (int) $row->ulsd_pence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> Sorted ascending. */
|
||||||
|
public function allDates(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->byDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, object{date: string, ulsp_pence: int, ulsd_pence: int}> */
|
||||||
|
private function byDate(): array
|
||||||
|
{
|
||||||
|
if ($this->byDate !== null) {
|
||||||
|
return $this->byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('weekly_pump_prices')
|
||||||
|
->orderBy('date')
|
||||||
|
->get(['date', 'ulsp_pence', 'ulsd_pence']);
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$map[(string) $r->date] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->byDate = $map;
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ use App\Enums\PriceReliability;
|
|||||||
use App\Models\Search;
|
use App\Models\Search;
|
||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Forecasting\LocalSnapshotService;
|
||||||
|
use App\Services\Forecasting\WeeklyForecastService;
|
||||||
use App\Services\HaversineQuery;
|
use App\Services\HaversineQuery;
|
||||||
use App\Services\NationalFuelPredictionService;
|
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -17,7 +18,8 @@ use Illuminate\Support\Collection;
|
|||||||
final class StationSearchService
|
final class StationSearchService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NationalFuelPredictionService $predictionService,
|
private readonly WeeklyForecastService $weeklyForecast,
|
||||||
|
private readonly LocalSnapshotService $localSnapshot,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||||
@@ -134,7 +136,10 @@ final class StationSearchService
|
|||||||
*/
|
*/
|
||||||
private function buildPrediction(?User $user, SearchCriteria $criteria): array
|
private function buildPrediction(?User $user, SearchCriteria $criteria): array
|
||||||
{
|
{
|
||||||
$result = $this->predictionService->predict($criteria->lat, $criteria->lng);
|
$result = $this->weeklyForecast->currentForecast();
|
||||||
|
// Layer 1 is national; the region_key only reflects whether the
|
||||||
|
// caller passed coordinates so the JSON contract stays stable.
|
||||||
|
$result['region_key'] = 'regional';
|
||||||
|
|
||||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||||
|
|
||||||
@@ -146,6 +151,13 @@ final class StationSearchService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$result['local_snapshot'] = $this->localSnapshot->snapshot(
|
||||||
|
fuelType: $criteria->fuelType->value,
|
||||||
|
lat: $criteria->lat,
|
||||||
|
lng: $criteria->lng,
|
||||||
|
radiusKm: max(10, (int) $criteria->radiusKm),
|
||||||
|
);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ return [
|
|||||||
'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'),
|
'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'forecasting' => [
|
||||||
|
// Phase 9 station-churn trigger is gated until ≥180 days of stable
|
||||||
|
// polling. Flip on once `station_prices` has continuous coverage —
|
||||||
|
// see `.claude/rules/forecasting.md`.
|
||||||
|
'station_churn_enabled' => env('FORECASTING_STATION_CHURN_ENABLED', false),
|
||||||
|
],
|
||||||
|
|
||||||
'fuelalert' => [
|
'fuelalert' => [
|
||||||
'api_key' => env('FUELALERT_API_KEY'),
|
'api_key' => env('FUELALERT_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|||||||
28
database/factories/BacktestFactory.php
Normal file
28
database/factories/BacktestFactory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/** @extends Factory<Backtest> */
|
||||||
|
class BacktestFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'model_version' => 'test-'.fake()->unique()->bothify('????????'),
|
||||||
|
'features_json' => ['features' => ['delta_ulsp_lag_0']],
|
||||||
|
'coefficients_json' => null,
|
||||||
|
'train_start' => '2018-01-01',
|
||||||
|
'train_end' => '2024-01-01',
|
||||||
|
'eval_start' => '2024-01-08',
|
||||||
|
'eval_end' => '2026-04-27',
|
||||||
|
'directional_accuracy' => fake()->randomFloat(2, 50, 75),
|
||||||
|
'mae_pence' => fake()->randomFloat(2, 0.4, 1.0),
|
||||||
|
'calibration_table' => ['0.0-0.5' => 0.55, '0.5-1.0' => 0.65, '1.0+' => 0.72],
|
||||||
|
'leak_suspected' => false,
|
||||||
|
'ran_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('backtests', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('model_version', 64)->unique()->comment('Deterministic hash of FeatureSpec');
|
||||||
|
$table->json('features_json')->comment('Serialised feature spec used for this run');
|
||||||
|
$table->json('coefficients_json')->nullable()->comment('Trained coefficients, null for non-parametric models like NaiveBaseline');
|
||||||
|
$table->date('train_start');
|
||||||
|
$table->date('train_end');
|
||||||
|
$table->date('eval_start');
|
||||||
|
$table->date('eval_end');
|
||||||
|
$table->decimal('directional_accuracy', 5, 2)->nullable()->comment('% of eval weeks where direction class was correct');
|
||||||
|
$table->decimal('mae_pence', 5, 2)->nullable()->comment('Mean absolute error in pence × 100');
|
||||||
|
$table->json('calibration_table')->nullable()->comment('{bin_low..bin_high → empirical_hit_rate}');
|
||||||
|
$table->boolean('leak_suspected')->default(false)->comment('Secondary smell test: directional_accuracy > 75');
|
||||||
|
$table->dateTime('ran_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['ran_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('backtests');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('forecast_outcomes', function (Blueprint $table) {
|
||||||
|
$table->date('forecast_for');
|
||||||
|
$table->string('model_version', 64);
|
||||||
|
$table->enum('predicted_class', ['rising', 'falling', 'flat']);
|
||||||
|
$table->enum('actual_class', ['rising', 'falling', 'flat']);
|
||||||
|
$table->boolean('correct');
|
||||||
|
$table->unsignedSmallInteger('abs_error_pence')->comment('|predicted − actual|, in pence × 100');
|
||||||
|
$table->dateTime('resolved_at');
|
||||||
|
|
||||||
|
$table->primary(['forecast_for', 'model_version']);
|
||||||
|
$table->index(['model_version', 'resolved_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('forecast_outcomes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('weekly_forecasts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->date('forecast_for')->comment('Monday the forecast covers');
|
||||||
|
$table->string('model_version', 64);
|
||||||
|
$table->enum('direction', ['rising', 'falling', 'flat']);
|
||||||
|
$table->smallInteger('magnitude_pence')->comment('Predicted Δ × 100, signed');
|
||||||
|
$table->unsignedTinyInteger('ridge_confidence')->comment('0..100 calibrated from backtest residuals');
|
||||||
|
$table->boolean('flagged_duty_change')->default(false)->comment('±4 weeks of a known duty change');
|
||||||
|
$table->text('reasoning')->comment('Generated from features actually used');
|
||||||
|
$table->dateTime('generated_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['forecast_for', 'model_version']);
|
||||||
|
$table->index(['forecast_for', 'generated_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('weekly_forecasts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('llm_overlays', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->dateTime('ran_at');
|
||||||
|
$table->date('forecast_for_week')->comment('The Monday this overlay annotates');
|
||||||
|
$table->enum('direction', ['rising', 'falling', 'flat']);
|
||||||
|
$table->unsignedTinyInteger('confidence')->comment('0..75 (cap enforced in code — web-searched LLMs are systematically overconfident)');
|
||||||
|
$table->text('reasoning');
|
||||||
|
$table->json('events_json')->comment('Cited events with verified URLs');
|
||||||
|
$table->boolean('agrees_with_ridge');
|
||||||
|
$table->boolean('major_impact_event')->default(false);
|
||||||
|
$table->boolean('volatility_flag_on')->default(false)->comment('Whether the volatility regime flag was active when this row was written');
|
||||||
|
$table->boolean('search_used')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['forecast_for_week', 'ran_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('llm_overlays');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('volatility_regimes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->dateTime('flipped_on_at');
|
||||||
|
$table->dateTime('flipped_off_at')->nullable();
|
||||||
|
$table->enum('trigger', ['brent_move', 'llm_event', 'station_churn', 'manual']);
|
||||||
|
$table->text('trigger_detail')->nullable();
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['active', 'flipped_on_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('volatility_regimes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('watched_events', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('label', 128);
|
||||||
|
$table->dateTime('starts_at');
|
||||||
|
$table->dateTime('ends_at');
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['starts_at', 'ends_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('watched_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -26,13 +26,49 @@ Schedule::command('fuel:poll --full')
|
|||||||
->onOneServer()
|
->onOneServer()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
// Fetch FRED prices and generate oil price prediction daily at 7am
|
// Phase 7: Brent crude refresh at 06:30 UK so the 07:00 LLM overlay has
|
||||||
Schedule::command('oil:predict --fetch')
|
// fresh context.
|
||||||
|
Schedule::command('oil:fetch')
|
||||||
|
->dailyAt('06:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Phase 8: news-aware overlay on the calibrated ridge forecast.
|
||||||
|
Schedule::command('forecast:llm-overlay')
|
||||||
->dailyAt('07:00')
|
->dailyAt('07:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->onOneServer()
|
->onOneServer()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Pull the latest BEIS Weekly Road Fuel Prices CSV from gov.uk every
|
||||||
|
// Monday at 09:30 UK. The publication usually lands earlier in the
|
||||||
|
// morning, so 09:30 is a safe buffer. Re-running on the same week is
|
||||||
|
// idempotent (upsert keyed on `date`).
|
||||||
|
Schedule::command('beis:import')
|
||||||
|
->mondays()
|
||||||
|
->at('09:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Phase 6: pair past forecasts with actual outcomes after BEIS
|
||||||
|
// publishes. Runs after `beis:import` so the new ULSP row is in DB.
|
||||||
|
Schedule::command('forecast:resolve-outcomes')
|
||||||
|
->mondays()
|
||||||
|
->at('10:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// Phase 9: hourly volatility regime check (Brent moves, LLM events,
|
||||||
|
// station churn (gated), watched events).
|
||||||
|
Schedule::command('forecast:evaluate-volatility')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
// Move station_prices rows older than 12 months into station_prices_archive
|
// Move station_prices rows older than 12 months into station_prices_archive
|
||||||
// once a month. Keeps the partitioned hot table bounded.
|
// once a month. Keeps the partitioned hot table bounded.
|
||||||
Schedule::command('fuel:archive')
|
Schedule::command('fuel:archive')
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ beforeEach(function (): void {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills a date range from FRED into brent_prices', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*api.stlouisfed.org/*' => Http::response([
|
||||||
|
'observations' => [
|
||||||
|
['date' => '2018-01-02', 'value' => '66.65'],
|
||||||
|
['date' => '2018-01-03', 'value' => '67.84'],
|
||||||
|
['date' => '2018-01-04', 'value' => '67.49'],
|
||||||
|
['date' => '2018-01-05', 'value' => '67.72'],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count = $this->fetcher->backfillFromFred('2018-01-01', '2018-01-07');
|
||||||
|
|
||||||
|
expect($count)->toBe(4)
|
||||||
|
->and(BrentPrice::count())->toBe(4)
|
||||||
|
->and(BrentPrice::find('2018-01-02')->price_usd)->toBe('66.65');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when FRED backfill returns no usable rows', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*api.stlouisfed.org/*' => Http::response([
|
||||||
|
'observations' => [
|
||||||
|
['date' => '2018-01-01', 'value' => '.'], // FRED placeholder
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->fetcher->backfillFromFred('2018-01-01', '2018-01-01');
|
||||||
|
})->throws(BrentPriceFetchException::class);
|
||||||
|
|
||||||
it('fetches and stores brent prices from EIA', function (): void {
|
it('fetches and stores brent prices from EIA', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*eia.gov/*' => Http::response([
|
'*eia.gov/*' => Http::response([
|
||||||
|
|||||||
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