feat(admin): add Filament resources for the forecasting stack

Adds three resources under a new "Forecasting" navigation group: a full-CRUD
WatchedEventResource for the Layer 5 volatility detector, plus read-only
WeeklyForecastResource and BacktestResource so the ridge model output and
its calibration can be inspected without SQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-05-03 09:13:05 +01:00
parent 1c46667f56
commit 8dad223d06
25 changed files with 1289 additions and 0 deletions

View File

@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
case Data;
case Forecasting;
case System;
public function getLabel(): string
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) {
self::Users => 'Users',
self::Data => 'Data',
self::Forecasting => 'Forecasting',
self::System => 'System',
};
}
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) {
self::Users => 'heroicon-o-users',
self::Data => 'heroicon-o-circle-stack',
self::Forecasting => null,
self::System => 'heroicon-o-cog-6-tooth',
};
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Backtests;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Backtests\Pages\ListBacktests;
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
use App\Models\Backtest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class BacktestResource extends Resource
{
protected static ?string $model = Backtest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Backtests';
protected static ?int $navigationSort = 3;
public static function infolist(Schema $schema): Schema
{
return BacktestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return BacktestsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListBacktests::route('/'),
'view' => ViewBacktest::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ListRecords;
class ListBacktests extends ListRecords
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBacktest extends ViewRecord
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\Backtests\Schemas;
use App\Models\Backtest;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class BacktestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Run')->columns(3)->schema([
TextEntry::make('model_version')->columnSpanFull(),
TextEntry::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%'),
TextEntry::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p'),
IconEntry::make('leak_suspected')
->label('Leak suspected')
->boolean()
->trueColor('danger'),
TextEntry::make('train_start')->date('d M Y'),
TextEntry::make('train_end')->date('d M Y'),
TextEntry::make('eval_start')->date('d M Y'),
TextEntry::make('eval_end')->date('d M Y'),
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
]),
Section::make('Calibration table')
->description('Empirical hit rate per magnitude bin from the eval window.')
->schema([
KeyValueEntry::make('calibration_table')
->hiddenLabel()
->keyLabel('Magnitude bin')
->valueLabel('Empirical hit rate')
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
->all())
->columnSpanFull(),
]),
Section::make('Feature spec')->schema([
KeyValueEntry::make('features_json')
->hiddenLabel()
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
->columnSpanFull(),
]),
Section::make('Coefficients')
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
->collapsed()
->schema([
TextEntry::make('coefficients_json')
->hiddenLabel()
->state(fn (Backtest $record): string => json_encode(
$record->coefficients_json,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) ?: '')
->columnSpanFull(),
]),
]);
}
/**
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
* into JSON strings rather than dropping them.
*
* @param array<string, mixed>|null $features
* @return array<string, string>
*/
protected static function flattenForKeyValue(?array $features): array
{
return collect($features ?? [])
->mapWithKeys(fn ($value, $key) => [
(string) $key => is_scalar($value)
? (string) $value
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
])
->all();
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\Backtests\Tables;
use App\Models\Backtest;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BacktestsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('model_version')
->searchable()
->limit(32)
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
TextColumn::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%')
->color(fn (Backtest $record) => self::accuracyColor($record))
->sortable(),
TextColumn::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p')
->sortable(),
IconColumn::make('leak_suspected')
->label('Leak?')
->boolean()
->trueColor('danger'),
TextColumn::make('eval_start')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('eval_end')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ran_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('ran_at', 'desc')
->filters([
Filter::make('leak_suspected')
->label('Suspicious accuracy (leak suspected)')
->toggle()
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
Filter::make('below_ship_gate')
->label('Below ship gate')
->toggle()
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function accuracyColor(Backtest $record): ?string
{
if ($record->directional_accuracy === null) {
return null;
}
$accuracy = (float) $record->directional_accuracy;
if ($accuracy > 75 && $record->leak_suspected) {
return 'danger';
}
if ($accuracy < 60) {
return 'danger';
}
if ($accuracy < 62) {
return 'warning';
}
if ($accuracy <= 75) {
return 'success';
}
return null;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWatchedEvent extends CreateRecord
{
protected static string $resource = WatchedEventResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWatchedEvent extends EditRecord
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWatchedEvents extends ListRecords
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WatchedEventForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextInput::make('label')
->required()
->maxLength(128)
->helperText('Short geopolitical event label, e.g. "Iran tensions AprMay 2026".'),
DateTimePicker::make('starts_at')
->label('Starts at')
->required(),
DateTimePicker::make('ends_at')
->label('Ends at')
->required()
->after('starts_at'),
Textarea::make('notes')
->maxLength(2000)
->rows(4)
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Tables;
use App\Models\WatchedEvent;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WatchedEventsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('label')
->searchable()
->sortable()
->limit(60)
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
TextColumn::make('starts_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('ends_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
TextColumn::make('notes')
->limit(50)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('starts_at', 'desc')
->filters([
Filter::make('currently_active')
->label('Currently active')
->toggle()
->query(fn (Builder $query) => $query
->where('starts_at', '<=', now())
->where('ends_at', '>=', now())),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
protected static function isActive(WatchedEvent $record): bool
{
$now = now();
return $record->starts_at !== null
&& $record->ends_at !== null
&& $record->starts_at->lessThanOrEqualTo($now)
&& $record->ends_at->greaterThanOrEqualTo($now);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\WatchedEvents;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
use App\Models\WatchedEvent;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WatchedEventResource extends Resource
{
protected static ?string $model = WatchedEvent::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Watched Events';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return WatchedEventForm::configure($schema);
}
public static function table(Table $table): Table
{
return WatchedEventsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWatchedEvents::route('/'),
'create' => CreateWatchedEvent::route('/create'),
'edit' => EditWatchedEvent::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ListRecords;
class ListWeeklyForecasts extends ListRecords
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ViewRecord;
class ViewWeeklyForecast extends ViewRecord
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
use App\Models\WeeklyForecast;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeeklyForecastInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Forecast')->columns(3)->schema([
TextEntry::make('forecast_for')->date('d M Y'),
TextEntry::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextEntry::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)),
TextEntry::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null),
IconEntry::make('flagged_duty_change')
->label('Duty change adjacent')
->boolean()
->trueColor('warning'),
TextEntry::make('generated_at')->dateTime('d M Y H:i'),
]),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded.'),
]),
Section::make('Model')
->description('Calibration table from the matching backtest determines the displayed confidence.')
->schema([
TextEntry::make('model_version')->columnSpanFull(),
]),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Tables;
use App\Models\WeeklyForecast;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WeeklyForecastsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('forecast_for')
->label('Forecast for')
->date('d M Y')
->sortable(),
TextColumn::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextColumn::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence))
->sortable(),
TextColumn::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null)
->sortable(),
IconColumn::make('flagged_duty_change')
->label('Duty change')
->boolean()
->trueColor('warning'),
TextColumn::make('model_version')
->searchable()
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('forecast_for', 'desc')
->filters([
SelectFilter::make('direction')
->multiple()
->options([
'rising' => 'Rising',
'falling' => 'Falling',
'flat' => 'Flat',
]),
Filter::make('high_confidence')
->label('High confidence')
->toggle()
->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)),
Filter::make('flagged_duty_change')
->label('Duty-change-adjacent')
->toggle()
->query(fn (Builder $query) => $query->where('flagged_duty_change', true)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
use App\Models\WeeklyForecast;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class WeeklyForecastResource extends Resource
{
protected static ?string $model = WeeklyForecast::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Weekly Forecasts';
protected static ?int $navigationSort = 2;
public static function infolist(Schema $schema): Schema
{
return WeeklyForecastInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return WeeklyForecastsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListWeeklyForecasts::route('/'),
'view' => ViewWeeklyForecast::route('/{record}'),
];
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Models;
use Database\Factories\WatchedEventFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -13,6 +15,9 @@ use Illuminate\Database\Eloquent\Model;
])]
class WatchedEvent extends Model
{
/** @use HasFactory<WatchedEventFactory> */
use HasFactory;
protected function casts(): array
{
return [

View File

@@ -2,7 +2,9 @@
namespace App\Models;
use Database\Factories\WeeklyForecastFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
@@ -17,6 +19,9 @@ use Illuminate\Database\Eloquent\Model;
])]
class WeeklyForecast extends Model
{
/** @use HasFactory<WeeklyForecastFactory> */
use HasFactory;
protected function casts(): array
{
return [

View File

@@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Models\WatchedEvent;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<WatchedEvent> */
class WatchedEventFactory extends Factory
{
public function definition(): array
{
$startsAt = fake()->dateTimeBetween('-30 days', '+30 days');
$endsAt = (clone $startsAt)->modify('+'.fake()->numberBetween(1, 14).' days');
return [
'label' => fake()->sentence(3),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'notes' => fake()->boolean() ? fake()->paragraph() : null,
];
}
public function active(): static
{
return $this->state([
'starts_at' => now()->subDays(2),
'ends_at' => now()->addDays(2),
]);
}
public function inactive(): static
{
return $this->state([
'starts_at' => now()->subDays(30),
'ends_at' => now()->subDays(15),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\WeeklyForecast;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<WeeklyForecast> */
class WeeklyForecastFactory extends Factory
{
public function definition(): array
{
return [
'forecast_for' => now()->startOfWeek()->toDateString(),
'model_version' => 'ridge-'.fake()->unique()->bothify('????????'),
'direction' => fake()->randomElement(['rising', 'falling', 'flat']),
'magnitude_pence' => fake()->numberBetween(-300, 300),
'ridge_confidence' => fake()->numberBetween(20, 90),
'flagged_duty_change' => false,
'reasoning' => fake()->paragraph(),
'generated_at' => now(),
];
}
}

View File

@@ -0,0 +1,332 @@
# Filament Admin Resources for the Forecasting Stack
## Goal
Add three Filament v5 admin resources for the new forecasting tables.
The forecasting pipeline has been built end-to-end (see
`docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md`) but
the new tables have no admin UI — the only way to inspect them is via
SQL or `php artisan tinker`.
This plan is self-contained: a fresh Claude session reading only this
document, the spec doc above, and CLAUDE.md should be able to ship the
work without referring back to any other conversation.
---
## Filament version + project conventions
This project uses **Filament v5**. CLAUDE.md captures the namespace
rules — copy-pasted here so you don't have to look:
- Form fields (`TextInput`, `Select`, etc.) → `Filament\Forms\Components\`
- Infolist entries (`TextEntry`, `IconEntry`) → `Filament\Infolists\Components\`
- Layout components (`Grid`, `Section`, `Fieldset`) → `Filament\Schemas\Components\`
- Schema utilities (`Get`, `Set`) → `Filament\Schemas\Components\Utilities\`
- Actions (`DeleteAction`, `CreateAction`) → `Filament\Actions\` (never sub-namespaced)
- Icons → `Filament\Support\Icons\Heroicon` enum
Use static `make()` everywhere. For conditional form logic use
`Get $get` from `Filament\Schemas\Components\Utilities\Get`.
**Before writing the first resource, read one existing resource in the
project to confirm house style.** Good candidates:
- `app/Filament/Resources/ApiLogResource.php` (read-only, similar shape
to `BacktestResource` and `WeeklyForecastResource`)
- Any Stations/Search resource for full-CRUD patterns relevant to
`WatchedEventResource`
Resources auto-discover by directory scan — no panel registration
needed. Use `php artisan make:filament-resource <Model> --no-interaction`
to scaffold each.
---
## Resources to build (in this order)
### 1. WatchedEventResource — full CRUD ★ highest priority
**Why first:** the *only* table with write operations the operator
actually needs. The Layer 5 volatility detector reads
`watched_events` to fire its manual trigger ("Iran tensions AprMay
2026" etc.). Without this resource the only way to add a row is via
SQL — which defeats the point of a manual flag.
**Model:** `App\Models\WatchedEvent` (already exists).
**Schema (already migrated):**
```
id BIGINT PK
label VARCHAR(128)
starts_at DATETIME
ends_at DATETIME
notes TEXT NULL
timestamps
INDEX (starts_at, ends_at)
```
**Operations:** Create, Edit, Delete, List, View.
**Form (Create + Edit):**
- `TextInput::make('label')->required()->maxLength(128)` — short
geopolitical event label, e.g. "Iran tensions AprMay 2026".
- `DateTimePicker::make('starts_at')->required()`
when the event period starts.
- `DateTimePicker::make('ends_at')->required()->after('starts_at')`
when the event period ends. Used by Layer 5 to decide if today is
covered.
- `Textarea::make('notes')->maxLength(2000)` — optional context for
future-you / other operators.
**Table (List):**
Columns:
- `label` — searchable.
- `starts_at` — date/time, sortable.
- `ends_at` — date/time, sortable.
- A computed badge column showing **"Active"** (green) when
`now()` is between `starts_at` and `ends_at`, else "Inactive" (grey).
Implementation: `IconColumn` or `TextColumn` with `state(closure)`
using a Get-style accessor on the model.
- `notes` — toggleable, hidden by default.
Default sort: `starts_at DESC` so the latest event is on top.
**Filters:**
- "Currently active" — toggle filter that adds
`where('starts_at', '<=', now())->where('ends_at', '>=', now())`.
**No infolist needed** — the form view in edit mode is enough.
---
### 2. WeeklyForecastResource — read-only
**Why:** daily/weekly check of "what did the model predict?". Most-
viewed read-only page once forecasts start landing.
**Model:** `App\Models\WeeklyForecast` (already exists, casts in place).
**Schema:**
```
id BIGINT PK
forecast_for DATE — Monday the forecast covers
model_version VARCHAR(64)
direction ENUM('rising','falling','flat')
magnitude_pence SMALLINT — predicted Δ × 100, signed
ridge_confidence TINYINT UNSIGNED — 0..100
flagged_duty_change BOOLEAN
reasoning TEXT
generated_at DATETIME
timestamps
UNIQUE (forecast_for, model_version)
INDEX (forecast_for, generated_at)
```
**Operations:** List, View only. No create / edit / delete (the
ridge model writes these rows; humans never edit forecasts).
In the resource, set:
```php
public static function canCreate(): bool { return false; }
public static function canEdit(Model $record): bool { return false; }
public static function canDelete(Model $record): bool { return false; }
```
**Table:**
Columns (in order):
- `forecast_for` — date, sortable, default sort DESC.
- `direction``BadgeColumn` with three colors:
- `rising` → orange
- `falling` → green (cheaper next week is good news for users)
- `flat` → grey
- `magnitude_pence` — show as `TextColumn` formatted as `X.Xp` (divide
by 100). Use `state(fn ($r) => round($r->magnitude_pence / 100, 1).'p')`.
- `ridge_confidence` — show as `X%`. Color the cell amber when < 40,
default otherwise.
- `flagged_duty_change``IconColumn::boolean()`, only show when true
(use `visibleFrom` or just always show with off icon hidden).
- `model_version` — toggleable, default hidden. Searchable.
- `generated_at` — toggleable, default hidden.
Default sort: `forecast_for DESC`.
**Filters:**
- `direction` — multi-select.
- "High confidence" — toggle: `where('ridge_confidence', '>=', 70)`.
- "Duty-change-adjacent" — toggle: `where('flagged_duty_change', true)`.
**Infolist (View page):**
Single-record view should display:
- All columns above.
- The full `reasoning` text in a wide `TextEntry` (it's the
human-readable explanation generated by `ReasoningGenerator`).
- A small `Section::make('Model')` panel with `model_version` as
read-only and a hint "calibration table from the matching backtest
determines the displayed confidence".
---
### 3. BacktestResource — read-only
**Why:** model-health audit. Operator needs to see "is the latest
ridge run still beating the gate? has anything regressed?" without
SQL.
**Model:** `App\Models\Backtest` (already exists, casts in place).
**Schema:**
```
id BIGINT PK
model_version VARCHAR(64) UNIQUE
features_json JSON
coefficients_json JSON NULL
train_start DATE
train_end DATE
eval_start DATE
eval_end DATE
directional_accuracy DECIMAL(5,2) NULL
mae_pence DECIMAL(5,2) NULL
calibration_table JSON NULL
leak_suspected BOOLEAN
ran_at DATETIME
timestamps
INDEX (ran_at)
```
**Operations:** List, View only. Disable create / edit / delete.
**Table:**
Columns:
- `model_version` — searchable. Truncate display at 32 chars
(`limit(32)`).
- `directional_accuracy``TextColumn` formatted `X.X%`. Apply
conditional color:
- red if `< 60`
- amber if `6062` (marginal, per spec)
- green if `6275` (ship gate)
- red if `> 75` AND `leak_suspected = true` (suspicious)
- `mae_pence``TextColumn` formatted `X.XXp`.
- `leak_suspected``IconColumn::boolean()` red icon.
- `eval_start` and `eval_end` — toggleable, default hidden.
- `ran_at` — sortable, default sort DESC.
**Filters:**
- "Suspicious accuracy (leak_suspected)" — toggle.
- "Below ship gate" — toggle: `where('directional_accuracy', '<', 62)`.
**Infolist (View page):**
This is the most useful screen of the three because the JSON columns
are where the model lives:
- All scalar columns (model_version, accuracy, MAE, leak flag).
- `KeyValueEntry::make('calibration_table')->state(fn ($r) =>
collect($r->calibration_table ?? [])->mapWithKeys(fn ($v, $k) =>
[$k => round($v * 100, 1).'%'])->all())` — render the
`{magnitude_bin → empirical_hit_rate}` map as a key-value table.
- `KeyValueEntry::make('features_json')` — show the feature spec.
- `coefficients_json` — collapsed code block, only shown when
`coefficients_json IS NOT NULL` (the naive baseline has it null).
Use `visible(fn ($r) => $r->coefficients_json !== null)`.
---
## Common conventions
Each resource:
- Uses `php artisan make:filament-resource <Model> --no-interaction`
to scaffold (or copy ApiLogResource as a template).
- Lives in `app/Filament/Resources/` — auto-discovered.
- Has a Heroicon nav icon. Suggestions:
- `WatchedEventResource``Heroicon::Flag`
- `WeeklyForecastResource``Heroicon::ChartBar`
- `BacktestResource``Heroicon::Beaker`
- Has a `navigationGroup = 'Forecasting'` so all three cluster in the
sidebar (set as a static property on the resource class).
- Read-only resources should `protected static bool $shouldRegisterNavigation = true;`
but disable mutating actions per the snippet above.
---
## Tests
Per CLAUDE.md test enforcement: every resource needs at least one
Pest test. Use `pestphp/pest-plugin-livewire` (already installed —
`use function Pest\Livewire\livewire`).
Authenticate as an admin user in `beforeEach`:
```php
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
```
Minimum coverage per resource (one or two assertions each is enough
— Filament's framework handles most of the heavy lifting):
**WatchedEventResource:**
- `it lists watched events` — create a couple, assert `livewire(ListWatchedEvents::class)->assertCanSeeTableRecords($events)`.
- `it creates a watched event from the form` — fill form, call create, assert DB row.
- `it validates ends_at is after starts_at` — submit with bad dates, assert form error.
**WeeklyForecastResource:**
- `it lists weekly forecasts sorted by forecast_for desc`.
- `it disables create and edit` — assert `canCreate()` and `canEdit()` return false.
**BacktestResource:**
- `it lists backtests`.
- `it shows the calibration table on the view page` — create a Backtest with a known calibration_table, view, assert the rendered key-value entries appear.
- `it disables create and edit`.
Tests live under `tests/Feature/Admin/<Resource>Test.php` to match
the existing `OilPredictionResourceTest`-style location.
---
## Definition of Done
1. Three resources live, navigable from the admin sidebar under
"Forecasting".
2. WatchedEventResource supports full CRUD; the other two are
list+view only.
3. Pest tests for each resource pass.
4. `php artisan test --compact --parallel` shows the full suite green
(currently 320 tests + however many you add).
5. `vendor/bin/pint --dirty --format agent` reports `{"result":"pass"}`.
6. The dashboard widget `StatsOverviewWidget::weeklyForecastStat()`
already reads from `weekly_forecasts` — no changes required there.
---
## What you do NOT need to do
- No migrations — all six tables already exist.
- No model changes — `WatchedEvent`, `WeeklyForecast`, `Backtest`
all have correct fillable + casts.
- No new commands or jobs — the data-population pipeline is wired.
- No StatsOverviewWidget changes.
- Do NOT build resources for `LlmOverlay`, `VolatilityRegime`,
`ForecastOutcome`, or `WeeklyPumpPrice`. Those were considered
Tier 2/3 and explicitly deferred.
---
## Reference
- `docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md`
the source of truth for what each table represents and how the
forecasting pipeline produces its rows.
- `CLAUDE.md` — project conventions, including the Filament rules
reproduced at the top of this plan.
- Existing resources in `app/Filament/Resources/` — copy house style.

View File

@@ -0,0 +1,59 @@
<?php
use App\Filament\Resources\Backtests\BacktestResource;
use App\Filament\Resources\Backtests\Pages\ListBacktests;
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
use App\Models\Backtest;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
it('lists backtests', function () {
$backtests = Backtest::factory()->count(3)->create();
Livewire::test(ListBacktests::class)
->assertOk()
->assertCanSeeTableRecords($backtests);
});
it('shows the calibration table on the view page', function () {
$backtest = Backtest::factory()->create([
'calibration_table' => [
'0.0-0.5' => 0.55,
'0.5-1.0' => 0.65,
'1.0+' => 0.72,
],
]);
Livewire::test(ViewBacktest::class, ['record' => $backtest->id])
->assertOk()
->assertSee('0.0-0.5')
->assertSee('55%')
->assertSee('1.0+')
->assertSee('72%');
});
it('disables create and edit', function () {
$backtest = Backtest::factory()->create();
expect(BacktestResource::canCreate())->toBeFalse()
->and(BacktestResource::canEdit($backtest))->toBeFalse()
->and(BacktestResource::canDelete($backtest))->toBeFalse();
});
it('filters backtests below the ship gate', function () {
$shippable = Backtest::factory()->create(['directional_accuracy' => 65.00]);
$marginal = Backtest::factory()->create(['directional_accuracy' => 58.00]);
Livewire::test(ListBacktests::class)
->filterTable('below_ship_gate')
->assertCanSeeTableRecords([$marginal])
->assertCanNotSeeTableRecords([$shippable]);
});

View File

@@ -0,0 +1,58 @@
<?php
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
use App\Models\User;
use App\Models\WatchedEvent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
it('lists watched events', function () {
$events = WatchedEvent::factory()->count(3)->create();
Livewire::test(ListWatchedEvents::class)
->assertOk()
->assertCanSeeTableRecords($events);
});
it('creates a watched event from the form', function () {
Livewire::test(CreateWatchedEvent::class)
->fillForm([
'label' => 'Iran tensions AprMay 2026',
'starts_at' => '2026-04-01 00:00:00',
'ends_at' => '2026-05-31 23:59:00',
'notes' => 'Geopolitical event affecting Brent crude.',
])
->call('create')
->assertHasNoFormErrors();
expect(WatchedEvent::where('label', 'Iran tensions AprMay 2026')->exists())->toBeTrue();
});
it('validates ends_at is after starts_at', function () {
Livewire::test(CreateWatchedEvent::class)
->fillForm([
'label' => 'Bad dates',
'starts_at' => '2026-05-01 00:00:00',
'ends_at' => '2026-04-01 00:00:00',
])
->call('create')
->assertHasFormErrors(['ends_at']);
});
it('filters to currently active events', function () {
$active = WatchedEvent::factory()->active()->create(['label' => 'Active event']);
$inactive = WatchedEvent::factory()->inactive()->create(['label' => 'Inactive event']);
Livewire::test(ListWatchedEvents::class)
->filterTable('currently_active')
->assertCanSeeTableRecords([$active])
->assertCanNotSeeTableRecords([$inactive]);
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use App\Models\User;
use App\Models\WeeklyForecast;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
it('lists weekly forecasts sorted by forecast_for desc', function () {
$older = WeeklyForecast::factory()->create(['forecast_for' => '2026-04-06']);
$newer = WeeklyForecast::factory()->create(['forecast_for' => '2026-05-04']);
Livewire::test(ListWeeklyForecasts::class)
->assertOk()
->assertCanSeeTableRecords([$newer, $older], inOrder: true);
});
it('disables create and edit', function () {
$forecast = WeeklyForecast::factory()->create();
expect(WeeklyForecastResource::canCreate())->toBeFalse();
expect(WeeklyForecastResource::canEdit($forecast))->toBeFalse();
expect(WeeklyForecastResource::canDelete($forecast))->toBeFalse();
});
it('renders the view page for a forecast', function () {
$forecast = WeeklyForecast::factory()->create([
'reasoning' => 'Brent stabilising; supermarket cycle entering bottom.',
]);
Livewire::test(ViewWeeklyForecast::class, ['record' => $forecast->id])
->assertOk()
->assertSee('Brent stabilising; supermarket cycle entering bottom.');
});