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