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

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