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:
@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
|
|
||||||
case Data;
|
case Data;
|
||||||
|
|
||||||
|
case Forecasting;
|
||||||
|
|
||||||
case System;
|
case System;
|
||||||
|
|
||||||
public function getLabel(): string
|
public function getLabel(): string
|
||||||
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'Users',
|
self::Users => 'Users',
|
||||||
self::Data => 'Data',
|
self::Data => 'Data',
|
||||||
|
self::Forecasting => 'Forecasting',
|
||||||
self::System => 'System',
|
self::System => 'System',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'heroicon-o-users',
|
self::Users => 'heroicon-o-users',
|
||||||
self::Data => 'heroicon-o-circle-stack',
|
self::Data => 'heroicon-o-circle-stack',
|
||||||
|
self::Forecasting => null,
|
||||||
self::System => 'heroicon-o-cog-6-tooth',
|
self::System => 'heroicon-o-cog-6-tooth',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Apr–May 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\WatchedEventFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -13,6 +15,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
])]
|
])]
|
||||||
class WatchedEvent extends Model
|
class WatchedEvent extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<WatchedEventFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\WeeklyForecastFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -17,6 +19,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
])]
|
])]
|
||||||
class WeeklyForecast extends Model
|
class WeeklyForecast extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<WeeklyForecastFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
39
database/factories/WatchedEventFactory.php
Normal file
39
database/factories/WatchedEventFactory.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
database/factories/WeeklyForecastFactory.php
Normal file
24
database/factories/WeeklyForecastFactory.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Apr–May
|
||||||
|
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 Apr–May 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 `60–62` (marginal, per spec)
|
||||||
|
- green if `62–75` (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.
|
||||||
59
tests/Feature/Admin/BacktestResourceTest.php
Normal file
59
tests/Feature/Admin/BacktestResourceTest.php
Normal 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]);
|
||||||
|
});
|
||||||
58
tests/Feature/Admin/WatchedEventResourceTest.php
Normal file
58
tests/Feature/Admin/WatchedEventResourceTest.php
Normal 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 Apr–May 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 Apr–May 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]);
|
||||||
|
});
|
||||||
43
tests/Feature/Admin/WeeklyForecastResourceTest.php
Normal file
43
tests/Feature/Admin/WeeklyForecastResourceTest.php
Normal 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.');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user