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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};