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 [