chore: retire legacy oil prediction pipeline

Removes everything that was made redundant by the new forecasting
stack. Per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md,
this was the cleanup planned at the end of Phase 4.

Deleted services and code:
- App\Services\Prediction\Signals\* (the old six-signal aggregator —
  trend, supermarket, day-of-week, brand-behaviour, stickiness,
  regional-momentum, oil — replaced by RidgeRegressionModel).
- App\Services\NationalFuelPredictionService (the post-Phase-4 thin
  shim; StationSearchService now depends on WeeklyForecastService
  directly, set up in the previous commit).
- App\Services\LlmPrediction\* (AbstractLlmPredictionProvider plus
  the four provider implementations — Anthropic, OpenAI, Gemini, and
  the OilPredictionProvider router. Replaced by LlmOverlayService).
- App\Services\BrentPricePredictor and App\Services\Ewma. The Ewma
  helper had no callers left after BrentPricePredictor went.
- App\Models\PricePrediction and its factory.
- App\Console\Commands\PredictOilPrices (the oil:predict command).
- App\Filament\Resources\OilPredictionResource and its Pages.

Schema and dashboard:
- Drop the price_predictions table via a new migration.
- Repoint the Filament StatsOverviewWidget tile from PricePrediction
  to WeeklyForecast so the dashboard reflects the new pipeline.
- Remove the OilPredictionProvider binding from AppServiceProvider.

Test cleanup:
- Delete tests for every retired service.
- Update StatsOverviewWidgetTest to seed weekly_forecasts instead of
  price_predictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-05-03 08:40:28 +01:00
parent ddd591ad47
commit 203200acb9
32 changed files with 61 additions and 2727 deletions

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPricePredictor;
use Illuminate\Console\Command;
use Throwable;
class PredictOilPrices extends Command
{
protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
protected $description = 'Generate a Brent crude oil price direction prediction';
public function handle(BrentPricePredictor $predictor): int
{
try {
$latest = $predictor->latestPrice();
if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
$message = sprintf(
'Prediction already generated for %s at %s.',
$latest->date->toDateString(),
$latest->prediction_generated_at->toDateTimeString(),
);
if (! $this->confirm($message.' Run again anyway?', default: false)) {
$this->info('Skipped.');
return self::SUCCESS;
}
}
$this->info('Generating prediction...');
$prediction = $predictor->generatePrediction();
if ($prediction === null) {
$this->error('Could not generate a prediction — not enough price data.');
return self::FAILURE;
}
$this->info(sprintf(
'Done. [%s] direction=%s confidence=%d%% — %s',
strtoupper($prediction->source->value),
$prediction->direction->value,
$prediction->confidence,
$prediction->reasoning,
));
} catch (Throwable $e) {
$this->error("Prediction failed: {$e->getMessage()}");
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\NavigationGroup;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
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 OilPredictionResource extends Resource
{
protected static ?string $model = PricePrediction::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
protected static ?string $navigationLabel = 'Oil Predictions';
protected static ?int $navigationSort = 3;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('predicted_for')
->date('d M Y')
->sortable(),
TextColumn::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextColumn::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextColumn::make('confidence')
->suffix('%')
->sortable(),
TextColumn::make('reasoning')
->limit(60)
->placeholder('—'),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('predicted_for', 'desc')
->filters([
SelectFilter::make('source')
->options([
PredictionSource::Llm->value => 'LLM',
PredictionSource::LlmWithContext->value => 'LLM + Context',
PredictionSource::Ewma->value => 'EWMA',
]),
SelectFilter::make('direction')
->options([
TrendDirection::Rising->value => 'Rising',
TrendDirection::Falling->value => 'Falling',
TrendDirection::Flat->value => 'Flat',
]),
Filter::make('predicted_for')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(function (Builder $query, array $data) {
$query
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
}),
])
->recordActions([
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Prediction')->schema([
TextEntry::make('predicted_for')->date('d M Y'),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextEntry::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextEntry::make('confidence')->suffix('%'),
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
])->columns(3),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => ListOilPredictions::route('/'),
'view' => ViewOilPrediction::route('/{record}'),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListOilPredictions extends ListRecords
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('runPrediction')
->label('Run Prediction Now')
->icon('heroicon-o-cpu-chip')
->requiresConfirmation()
->modalHeading('Run oil price prediction?')
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
->action(function () {
$result = Artisan::call('oil:predict', ['--force' => true]);
if ($result === 0) {
Notification::make()
->title('Prediction generated successfully')
->success()
->send();
} else {
Notification::make()
->title('Prediction failed')
->body('Check API Logs for details.')
->danger()
->send();
}
}),
];
}
}

View File

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

View File

@@ -3,10 +3,10 @@
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use App\Models\ApiLog; use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User; use App\Models\User;
use App\Models\WeeklyForecast;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +21,7 @@ class StatsOverviewWidget extends BaseWidget
$this->usersStat(), $this->usersStat(),
$this->searchesStat(), $this->searchesStat(),
$this->stationsStat(), $this->stationsStat(),
$this->oilPredictionStat(), $this->weeklyForecastStat(),
$this->apiErrorsStat(), $this->apiErrorsStat(),
]; ];
} }
@@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget
->color('success'); ->color('success');
} }
private function oilPredictionStat(): Stat private function weeklyForecastStat(): Stat
{ {
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first(); $forecast = WeeklyForecast::query()->latest('generated_at')->first();
if ($prediction === null) { if ($forecast === null) {
return Stat::make('Latest oil prediction', 'None') return Stat::make('Latest weekly forecast', 'None')
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color('gray'); ->color('gray');
} }
$ageHours = $prediction->generated_at->diffInHours(now()); $ageHours = $forecast->generated_at->diffInHours(now());
$color = $ageHours > 24 ? 'warning' : 'success'; $color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week
$value = $prediction->direction->label().' · '.$prediction->confidence.'%'; $directionLabel = ucfirst($forecast->direction);
$value = $directionLabel.' · '.$forecast->ridge_confidence.'%';
return Stat::make('Latest oil prediction', $value) return Stat::make('Latest weekly forecast', $value)
->description('Generated '.$prediction->generated_at->diffForHumans()) ->description('For week of '.$forecast->forecast_for->toDateString())
->url(route('filament.admin.resources.oil-predictions.index'))
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color($color); ->color($color);
} }

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Models;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
{
/** @use HasFactory<PricePredictionFactory> */
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'predicted_for' => 'date',
'source' => PredictionSource::class,
'direction' => TrendDirection::class,
'confidence' => 'integer',
'generated_at' => 'datetime',
];
}
/**
* Order by source quality: llm_with_context llm ewma.
* Use this whenever reading the "best" prediction for a given date.
*
* @param Builder<PricePrediction> $query
* @return Builder<PricePrediction>
*/
public function scopeBestFirst(Builder $query): Builder
{
$priority = [
PredictionSource::LlmWithContext->value,
PredictionSource::Llm->value,
PredictionSource::Ewma->value,
];
$cases = '';
foreach ($priority as $rank => $source) {
$cases .= " WHEN '$source' THEN $rank";
}
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
}
}

View File

@@ -4,11 +4,6 @@ namespace App\Providers;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\LlmPrediction\OpenAiPredictionProvider;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -25,15 +20,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind(OilPredictionProvider::class, function ($app) { // No bindings here. The legacy LLM prediction provider binding
$logger = $app->make(ApiLogger::class); // was removed when the Phase 4 ridge model + Phase 8
// LlmOverlayService replaced the old daily oil prediction.
return match (config('services.llm.provider')) {
'openai' => new OpenAiPredictionProvider($logger),
'gemini' => new GeminiPredictionProvider($logger),
default => new AnthropicPredictionProvider($logger),
};
});
} }
/** /**

View File

@@ -1,119 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly OilPredictionProvider $provider,
) {}
/**
* Return the latest BrentPrice row, or null if none exists.
*/
public function latestPrice(): ?BrentPrice
{
return BrentPrice::orderBy('date', 'desc')->first();
}
/**
* Try LLM first; persist EWMA only as a fallback when the LLM provider
* returns null. The downstream OilSignal already prefers LLM
* (llm_with_context > llm > ewma), so writing both rows on every run is
* dead weight 95% of the time. EWMA still acts as the safety net.
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('BrentPricePredictor: not enough price data', [
'rows' => $prices->count(),
]);
return null;
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
return $llm;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $ewma;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = Ewma::compute(array_slice($chronological, -3));
$ewma7 = Ewma::compute(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Services;
/**
* Exponentially-weighted moving average. Pure function used by
* BrentPricePredictor for the EWMA fallback prediction and by
* AnthropicPredictionProvider to enrich the basic-flow prompt.
*/
final class Ewma
{
public const float DEFAULT_ALPHA = 0.3;
/** @param float[] $prices Chronological order (oldest first). */
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = $alpha * $price + (1 - $alpha) * $ema;
}
return round($ema, 4);
}
}

View File

@@ -1,99 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
{
protected const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
protected readonly ApiLogger $apiLogger,
) {}
/**
* Default flow: gate on API key, call the provider, normalise the payload
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
* web-search) override `predict()` directly and reuse the helper methods.
*/
public function predict(Collection $prices): ?PricePrediction
{
$apiKey = $this->apiKey();
if ($apiKey === null) {
return null;
}
try {
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
return $payload === null ? null : $this->buildPrediction($payload);
} catch (Throwable $e) {
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
return null;
}
}
/** Returns the configured API key or null if not set. */
abstract protected function apiKey(): ?string;
/**
* Make the provider HTTP call and return the normalised payload, or null
* on failure (already logged by the implementer).
*
* @return array{direction: string, confidence: int, reasoning: string}|null
*/
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
/** @param Collection<int, BrentPrice> $prices */
protected function buildPriceList(Collection $prices): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
}
/** @param array{direction: string, confidence: int, reasoning: string} $input */
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error(static::class.': invalid direction', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'] ?? '',
'generated_at' => now(),
]);
}
protected function defaultPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -1,231 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Models\PricePrediction;
use App\Services\Ewma;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{
/**
* Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/
public function predict(Collection $prices): ?PricePrediction
{
if ($this->apiKey() === null) {
return null;
}
$prediction = $this->predictWithWebContext($prices);
return $prediction ?? $this->predictBasic($prices);
}
protected function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
/** {@inheritDoc} */
protected function callProvider(string $apiKey, string $priceList): ?array
{
return null;
}
/**
* Multi-turn web search phase, then a forced submit_prediction call.
* Phase 1: let the model search for recent oil/geopolitical news.
* Phase 2: force submit_prediction with the full conversation context.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
$messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]];
$url = 'https://api.anthropic.com/v1/messages';
try {
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
$submitResponse = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => $messages,
]));
if (! $submitResponse->successful()) {
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
return $input === null
? null
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
return null;
}
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
}
private function contextPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
First, search for recent news (last 48 hours) about:
- Brent crude oil price movements
- OPEC+ production decisions or announcements
- Major geopolitical events affecting oil supply
- Global demand signals (China economic data, US inventory reports)
Recent Brent crude prices (USD/barrel):
{$priceList}
After searching, you will be asked to submit your prediction.
PROMPT;
}
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
/** @return array{name: string, description: string, input_schema: array<string, mixed>} */
private function submitPredictionTool(): array
{
return [
'name' => 'submit_prediction',
'description' => 'Submit the final oil price direction prediction.',
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => [
'type' => 'string',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => self::LLM_MAX_CONFIDENCE,
],
'reasoning' => [
'type' => 'string',
'description' => 'One sentence explaining the prediction.',
],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
];
}
/** @param array<int, mixed> $content */
private function extractToolInput(array $content): ?array
{
$block = collect($content)->firstWhere('type', 'tool_use');
return $block['input'] ?? null;
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
{
protected function apiKey(): ?string
{
return config('services.gemini.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => $apiKey])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->defaultPrompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
],
]));
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['text' => $text]);
return null;
}
return $data;
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use Illuminate\Support\Collection;
interface OilPredictionProvider
{
/**
* Generate an oil price direction prediction from recent Brent crude prices.
* Returns null on failure, API key not configured, or insufficient data.
*
* @param Collection<int, BrentPrice> $prices Chronological Brent crude prices
*/
public function predict(Collection $prices): ?PricePrediction;
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
{
protected function apiKey(): ?string
{
return config('services.openai.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$url = 'https://api.openai.com/v1/chat/completions';
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken($apiKey)
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
],
],
],
'messages' => [[
'role' => 'user',
'content' => $this->defaultPrompt($priceList),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['data' => $data]);
return null;
}
return $data;
}
}

View File

@@ -1,414 +0,0 @@
<?php
namespace App\Services;
use App\Enums\FuelType;
use App\Models\StationPriceCurrent;
use App\Services\Prediction\Signals\BrandBehaviourSignal;
use App\Services\Prediction\Signals\DayOfWeekSignal;
use App\Services\Prediction\Signals\OilSignal;
use App\Services\Prediction\Signals\RegionalMomentumSignal;
use App\Services\Prediction\Signals\SignalContext;
use App\Services\Prediction\Signals\StickinessSignal;
use App\Services\Prediction\Signals\TrendSignal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const int PREDICTION_HORIZON_DAYS = 7;
public function __construct(
private readonly TrendSignal $trendSignal,
private readonly DayOfWeekSignal $dayOfWeekSignal,
private readonly BrandBehaviourSignal $brandBehaviourSignal,
private readonly StickinessSignal $stickinessSignal,
private readonly RegionalMomentumSignal $regionalMomentumSignal,
private readonly OilSignal $oilSignal,
) {}
/**
* @return array{
* fuel_type: string,
* current_avg: float,
* predicted_direction: string,
* predicted_change_pence: float,
* confidence_score: float,
* confidence_label: string,
* action: string,
* reasoning: string,
* prediction_horizon_days: int,
* region_key: string,
* methodology: string,
* signals: array
* }
*/
public function predict(?float $lat = null, ?float $lng = null): array
{
$fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null;
$context = new SignalContext($fuelType, $lat, $lng);
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->trendSignal->compute($context);
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
$stickiness = $this->stickinessSignal->compute($context);
$oil = $this->oilSignal->compute($context);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
$slope = $trend['slope'] ?? 0.0;
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
$confidenceLabel = match (true) {
$confidenceScore >= 70 => 'high',
$confidenceScore >= 40 => 'medium',
default => 'low',
};
$action = match ($direction) {
'up' => 'fill_now',
'down' => 'wait',
default => 'no_signal',
};
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [
'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg,
'predicted_direction' => $direction,
'predicted_change_pence' => $predictedChangePence,
'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel,
'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [
'trend' => $trend,
'day_of_week' => $dayOfWeek,
'brand_behaviour' => $brandBehaviour,
'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness,
'oil' => $oil,
],
];
}
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType->value)
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices_current.price_pence');
if ($avg !== null) {
return round((float) $avg / 100, 1);
}
}
$avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Aggregate enabled signals into a final direction + confidence score.
*
* Direction: weighted vote across signals that have a non-stable direction.
* stable signals do NOT dilute the directional vote.
*
* Confidence: weighted average of enabled signals' own confidence values,
* multiplied by an agreement coefficient (0..1) measuring how the signals
* line up with the chosen direction.
*
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
* @return array{0: string, 1: float}
*/
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{
$weights = $hasCoordinates
? [
'regionalMomentum' => 0.35,
'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.15,
'stickiness' => 0.10,
];
$directionalScoreSum = 0.0;
$directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$totalEnabledWeight += $weight;
$confidenceWeightedSum += $signal['confidence'] * $weight;
if ($signal['direction'] !== 'stable') {
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
$directionalWeightSum += $weight;
}
}
if ($totalEnabledWeight < 0.01) {
return ['stable', 0.0];
}
$normalised = $directionalWeightSum > 0.01
? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) {
$normalised >= 0.1 => 'up',
$normalised <= -0.1 => 'down',
default => 'stable',
};
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore];
}
/**
* How well the enabled signals line up with the chosen direction.
* - aligned signal: full credit (signal_confidence × weight)
* - one side stable, other directional: half credit
* - opposing signals: no credit
*
* Range: 0 (full disagreement) 1 (unanimous).
*
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{
$finalDir = match ($finalDirection) {
'up' => 1,
'down' => -1,
default => 0,
};
$credit = 0.0;
$maxCredit = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$maxCredit += $signal['confidence'] * $weight;
$signalDir = match ($signal['direction']) {
'up' => 1,
'down' => -1,
default => 0,
};
if ($signalDir === $finalDir) {
$credit += $signal['confidence'] * $weight;
} elseif ($signalDir === 0 || $finalDir === 0) {
$credit += 0.5 * $signal['confidence'] * $weight;
}
}
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
/**
* Yesterday / today / tomorrow snapshot + last-7-days series.
* Regional (50km) when coordinates are given, with national fallback when
* regional data is empty.
*
* @return array{
* yesterday_avg: ?float,
* today_avg: float,
* tomorrow_estimated_avg: ?float,
* yesterday_today_delta_pence: ?float,
* last_7_days_series: array<int, array{date: string, avg: float}>,
* last_7_days_change_pence: ?float,
* cheapest_day: ?array{date: string, avg: float},
* priciest_day: ?array{date: string, avg: float},
* is_regional: bool
* }
*/
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
{
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
$cheapestDay = null;
$priciestDay = null;
$weekChange = null;
if (count($series) >= 2) {
$byPrice = $series;
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
$cheapestDay = $byPrice[0];
$priciestDay = $byPrice[count($byPrice) - 1];
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
}
return [
'yesterday_avg' => $yesterdayAvg,
'today_avg' => $todayAvg,
'tomorrow_estimated_avg' => $tomorrowEstimated,
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
'last_7_days_series' => $series,
'last_7_days_change_pence' => $weekChange,
'cheapest_day' => $cheapestDay,
'priciest_day' => $priciestDay,
'is_regional' => $usedRegional,
];
}
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
{
$dateString = $date->toDateString();
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$regional = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->whereDate('station_prices.price_effective_at', $dateString)
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices.price_pence');
if ($regional !== null) {
return round((float) $regional / 100, 1);
}
}
$national = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->whereDate('price_effective_at', $dateString)
->avg('price_pence');
return $national !== null ? round((float) $national / 100, 1) : null;
}
/**
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
*/
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
{
$rows = collect();
$usedRegional = false;
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
$usedRegional = $rows->isNotEmpty();
}
if ($rows->isEmpty()) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
}
$series = $rows->map(fn ($r): array => [
'date' => (string) $r->day,
'avg' => round((float) $r->avg_price / 100, 1),
])->values()->all();
return [$series, $usedRegional];
}
/**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
*/
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
{
$parts = [];
if ($trend['enabled'] && abs($slope) >= self::SLOPE_THRESHOLD_PENCE) {
$parts[] = $trend['detail'];
}
if ($brandBehaviour['enabled'] && $brandBehaviour['direction'] !== 'stable') {
$parts[] = $brandBehaviour['detail'];
}
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
if (empty($parts)) {
return match ($direction) {
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
default => 'No clear pattern — fill up at the cheapest station near you now.',
};
}
return implode(' ', $parts);
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
abstract class AbstractSignal implements Signal
{
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
protected function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Least-squares linear regression. x = array index, y = value.
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
*/
protected function linearRegression(array $values): array
{
$n = count($values);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
}
$xMean = ($n - 1) / 2.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class BrandBehaviourSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class DayOfWeekSignal extends AbstractSignal
{
private const int MIN_DAYS = 21;
public function compute(SignalContext $context): array
{
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < self::MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
$parts = [];
$parts[] = abs($todayDeltaPence) < 0.1
? "Today ({$todayName}) is typically in line with the weekly average."
: sprintf(
'Today (%s) is typically %sp %s the weekly average.',
$todayName,
number_format(abs($todayDeltaPence), 1),
$todayDeltaPence > 0 ? 'above' : 'below',
);
$parts[] = abs($tomorrowDeltaPence) < 0.1
? "Tomorrow ({$tomorrowName}) is typically the same."
: sprintf(
'Tomorrow (%s) is typically %sp %s.',
$tomorrowName,
number_format(abs($tomorrowDeltaPence), 1),
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
);
if ($cheapestDow === $todayDow) {
$parts[] = 'Today is historically the cheapest day of the week.';
}
return [
'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction,
'detail' => implode(' ', $parts),
'data_points' => $uniqueDays,
'enabled' => true,
];
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
/**
* SQL dialect helpers for the small set of MySQL/SQLite differences the
* signal classes care about. Centralises the isSqlite ternaries that were
* duplicated across DayOfWeekSignal and StickinessSignal.
*/
final class DbDialect
{
private static function isSqlite(): bool
{
return DB::connection()->getDriverName() === 'sqlite';
}
/**
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
* Targets a column on the queried table.
*/
public static function dayOfWeekExpr(string $column): string
{
return self::isSqlite()
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
: "DAYOFWEEK({$column})";
}
/**
* Whole-day difference between MAX and MIN of a datetime column, suitable
* for use in an aggregate selectRaw.
*/
public static function maxMinDayDiffExpr(string $column): string
{
return self::isSqlite()
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
: "DATEDIFF(MAX({$column}), MIN({$column}))";
}
}

View File

@@ -1,63 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class OilSignal extends AbstractSignal
{
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
* fallback) covering today or later. Sourced from price_predictions,
* which OilPriceService populates daily.
*/
public function compute(SignalContext $context): array
{
$prediction = null;
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
$prediction = DB::table('price_predictions')
->where('source', $source)
->where('predicted_for', '>=', now()->toDateString())
->orderByDesc('predicted_for')
->orderByDesc('generated_at')
->first();
if ($prediction !== null) {
break;
}
}
if ($prediction === null) {
return $this->disabledSignal('No oil price prediction available');
}
$direction = match ($prediction->direction) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
$score = match ($direction) {
'up' => 1.0,
'down' => -1.0,
default => 0.0,
};
$confidence = round(((float) $prediction->confidence) / 100, 2);
return [
'score' => $score,
'confidence' => $confidence,
'direction' => $direction,
'detail' => sprintf(
'Brent crude %s (%s, %d%% confidence)',
$prediction->direction,
$prediction->source,
(int) $prediction->confidence,
),
'data_points' => 1,
'enabled' => true,
];
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Services\HaversineQuery;
use Illuminate\Support\Facades\DB;
final class RegionalMomentumSignal extends AbstractSignal
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float REGIONAL_RADIUS_KM = 50.0;
public function compute(SignalContext $context): array
{
if (! $context->hasCoordinates()) {
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
}
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
interface Signal
{
/**
* Evaluate the signal against the given context.
*
* Returns the canonical signal payload. Implementations may add extra
* keys beyond the base shape (e.g. trend adds slope + r_squared).
*
* @return array{
* score: float,
* confidence: float,
* direction: string,
* detail: string,
* data_points: int,
* enabled: bool,
* ...
* }
*/
public function compute(SignalContext $context): array;
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Enums\FuelType;
/**
* Inputs required to evaluate a prediction signal. Individual signals may
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
* RegionalMomentumSignal requires lat/lng to be non-null.
*/
final readonly class SignalContext
{
public function __construct(
public FuelType $fuelType,
public ?float $lat = null,
public ?float $lng = null,
) {}
public function hasCoordinates(): bool
{
return $this->lat !== null && $this->lng !== null;
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class StickinessSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,86 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class TrendSignal extends AbstractSignal
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float SLOPE_SATURATION_PENCE = 0.5;
private const int PREDICTION_HORIZON_DAYS = 7;
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
public function compute(SignalContext $context): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace Database\Factories;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\PricePrediction;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<PricePrediction> */
class PricePredictionFactory extends Factory
{
public function definition(): array
{
return [
'predicted_for' => fake()->dateTimeBetween('-30 days')->format('Y-m-d'),
'source' => fake()->randomElement(PredictionSource::cases()),
'direction' => fake()->randomElement(TrendDirection::cases()),
'confidence' => fake()->numberBetween(40, 85),
'reasoning' => fake()->sentence(12),
'generated_at' => now(),
];
}
public function llm(): static
{
return $this->state(['source' => PredictionSource::Llm]);
}
public function ewma(): static
{
return $this->state(['source' => PredictionSource::Ewma]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Retire the legacy oil-prediction pipeline. The new forecasting
* stack writes to `weekly_forecasts` (Layer 1 ridge) and
* `llm_overlays` (Layer 4 LLM news scan) instead. Per
* `docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md`
* this table and its associated services were due for removal at
* the end of Phase 4.
*/
public function up(): void
{
Schema::dropIfExists('price_predictions');
}
/**
* Re-creating the dropped table is not supported the legacy
* services that populated it have been deleted along with the
* table. To restore, revert the prediction-rebuild commits.
*/
public function down(): void
{
// intentionally empty; see docblock above
}
};

View File

@@ -1,27 +0,0 @@
<?php
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Models\PricePrediction;
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('renders the oil prediction list', function () {
$predictions = PricePrediction::factory()->count(3)->create();
Livewire::test(ListOilPredictions::class)
->assertOk()
->assertCanSeeTableRecords($predictions);
});
it('has a run prediction header action', function () {
Livewire::test(ListOilPredictions::class)
->assertActionExists('runPrediction');
});

View File

@@ -2,10 +2,11 @@
use App\Filament\Widgets\StatsOverviewWidget; use App\Filament\Widgets\StatsOverviewWidget;
use App\Models\ApiLog; use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Station; use App\Models\Station;
use App\Models\User; use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire; use Livewire\Livewire;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -18,7 +19,20 @@ beforeEach(function () {
it('renders the stats overview widget', function () { it('renders the stats overview widget', function () {
User::factory()->count(3)->create(); User::factory()->count(3)->create();
Station::factory()->count(2)->create(); Station::factory()->count(2)->create();
PricePrediction::factory()->create(['generated_at' => now()->subHours(2)]);
DB::table('weekly_forecasts')->insert([
'forecast_for' => now()->next(Carbon::MONDAY)->toDateString(),
'model_version' => 'ridge-v1-test',
'direction' => 'rising',
'magnitude_pence' => 80,
'ridge_confidence' => 65,
'flagged_duty_change' => false,
'reasoning' => 'test',
'generated_at' => now()->subHours(2),
'created_at' => now(),
'updated_at' => now(),
]);
ApiLog::factory()->count(2)->create(['status_code' => 200, 'error' => null, 'created_at' => now()->subMinutes(30)]); ApiLog::factory()->count(2)->create(['status_code' => 200, 'error' => null, 'created_at' => now()->subMinutes(30)]);
Livewire::test(StatsOverviewWidget::class) Livewire::test(StatsOverviewWidget::class)

View File

@@ -1,145 +0,0 @@
<?php
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\BrentPricePredictor;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->provider = Mockery::mock(OilPredictionProvider::class);
$this->predictor = new BrentPricePredictor($this->provider);
});
it('detects a rising trend when 3-day EWMA exceeds 7-day EWMA by threshold', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 70.0 + ($i * 2.0),
]));
$prediction = $this->predictor->generateEwmaPrediction($prices);
expect($prediction->direction)->toBe(TrendDirection::Rising)
->and($prediction->source)->toBe(PredictionSource::Ewma)
->and($prediction->confidence)->toBeGreaterThan(0)
->and($prediction->confidence)->toBeLessThanOrEqual(65);
});
it('detects a falling trend when 3-day EWMA falls below 7-day EWMA by threshold', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 85.0 - ($i * 2.0),
]));
$prediction = $this->predictor->generateEwmaPrediction($prices);
expect($prediction->direction)->toBe(TrendDirection::Falling);
});
it('returns flat when price movement is within threshold', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 75.0 + (($i % 2 === 0) ? 0.1 : -0.1),
]));
$prediction = $this->predictor->generateEwmaPrediction($prices);
expect($prediction->direction)->toBe(TrendDirection::Flat)
->and($prediction->confidence)->toBe(50);
});
it('returns null when fewer than 14 prices are available for EWMA', function (): void {
$prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(10 - $i)->toDateString(),
'price_usd' => 75.0,
]));
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
});
it('stores only the LLM prediction when the provider succeeds', function (): void {
seedPrices(20);
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::LlmWithContext,
'direction' => TrendDirection::Rising,
'confidence' => 70,
'reasoning' => 'Trend is up.',
'generated_at' => now(),
]));
$prediction = $this->predictor->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and(PricePrediction::count())->toBe(1)
->and(PricePrediction::where('source', PredictionSource::Ewma)->count())->toBe(0);
});
it('falls back to EWMA when provider returns null', function (): void {
seedPrices(20, slope: 0.8);
$this->provider->shouldReceive('predict')->once()->andReturn(null);
$prediction = $this->predictor->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::Ewma)
->and(PricePrediction::count())->toBe(1);
});
it('returns null when there is insufficient price data', function (): void {
BrentPrice::insert([
['date' => now()->subDays(2)->toDateString(), 'price_usd' => 75.0],
['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0],
]);
$this->provider->shouldNotReceive('predict');
expect($this->predictor->generatePrediction())->toBeNull()
->and(PricePrediction::count())->toBe(0);
});
it('flags latest brent price as prediction generated on success', function (): void {
seedPrices(20);
$this->provider->shouldReceive('predict')->once()->andReturn(null);
$this->predictor->generatePrediction();
$latest = BrentPrice::orderBy('date', 'desc')->first();
expect($latest->prediction_generated_at)->not->toBeNull();
});
it('does not flag when prediction cannot be generated', function (): void {
BrentPrice::insert([
['date' => now()->subDay()->toDateString(), 'price_usd' => 75.0],
]);
$this->provider->shouldNotReceive('predict');
$this->predictor->generatePrediction();
expect(BrentPrice::first()->prediction_generated_at)->toBeNull();
});
it('returns the latest price row', function (): void {
seedPrices(3);
expect($this->predictor->latestPrice())->not->toBeNull()
->and($this->predictor->latestPrice()->date->toDateString())->toBe(now()->toDateString());
});
function seedPrices(int $count, float $slope = 1.0): void
{
BrentPrice::insert(
collect(range(1, $count))->map(fn (int $i) => [
'date' => now()->subDays($count - $i)->toDateString(),
'price_usd' => 75.0 + ($i * $slope),
])->all()
);
}

View File

@@ -1,219 +0,0 @@
<?php
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
Http::preventStrayRequests();
config(['services.anthropic.api_key' => 'test-key']);
$this->provider = new AnthropicPredictionProvider(new ApiLogger);
});
it('returns null when api key is not configured', function (): void {
config(['services.anthropic.api_key' => null]);
$prices = fakePrices(14);
expect($this->provider->predict($prices))->toBeNull();
});
it('uses submit_prediction tool in the basic request', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Prices rising.'],
]],
]),
]);
// context request fails, falls back to basic
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Prices rising.'],
]],
]),
]);
$this->provider->predict(fakePrices(14));
Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => $t['name'] === 'submit_prediction');
});
});
it('returns a prediction with Llm source from basic tool use', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500) // context fails
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Consistent upward trend.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->direction)->toBe(TrendDirection::Rising)
->and($prediction->source)->toBe(PredictionSource::Llm)
->and($prediction->confidence)->toBe(72)
->and($prediction->reasoning)->toBe('Consistent upward trend.');
});
it('caps confidence at 85', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'falling', 'confidence' => 99, 'reasoning' => 'Very confident.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->confidence)->toBe(85);
});
it('returns null when tool_use block is missing from response', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Sorry, I cannot help.']],
]),
]);
expect($this->provider->predict(fakePrices(14)))->toBeNull();
});
it('sends web_search tool during context prediction phase', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Searched and analysed.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'flat', 'confidence' => 50, 'reasoning' => 'No clear trend.'],
]],
]),
]);
$this->provider->predict(fakePrices(20));
Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => ($t['type'] ?? '') === 'web_search_20250305');
});
});
it('returns LlmWithContext source when context prediction succeeds', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Analysed news.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning' => 'OPEC+ cuts support prices.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(20));
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and($prediction->direction)->toBe(TrendDirection::Rising);
});
it('continues on pause_turn during web search phase', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'pause_turn',
'content' => [['type' => 'server_tool_use', 'name' => 'web_search', 'input' => ['query' => 'Brent crude']]],
])
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Done searching.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'falling', 'confidence' => 60, 'reasoning' => 'Demand fears.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(20));
expect($prediction)->not->toBeNull()
->and($prediction->direction)->toBe(TrendDirection::Falling);
Http::assertSentCount(3);
});
it('falls back to basic prediction when context phase fails', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500) // context search fails
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 65, 'reasoning' => 'Rising trend.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->source)->toBe(PredictionSource::Llm);
});
// --- helpers ---
function fakePrices(int $count): Collection
{
return collect(range(1, $count))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays($count - $i)->toDateString(),
'price_usd' => 75.0 + $i,
]));
}

View File

@@ -1,394 +0,0 @@
<?php
use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPrice;
use App\Models\StationPriceCurrent;
use App\Services\NationalFuelPredictionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('returns no_signal when there is insufficient price history', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('stable')
->and($result['signals']['trend']['enabled'])->toBeFalse()
->and($result['action'])->toBe('no_signal');
});
it('detects rising trend from consistently increasing daily averages', function () {
$station = Station::factory()->create();
// 7 days of prices rising at ~100 pence/day
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['trend']['direction'])->toBe('up')
->and($result['signals']['trend']['enabled'])->toBeTrue()
->and($result['predicted_direction'])->toBe('up')
->and($result['action'])->toBe('fill_now');
});
it('detects falling trend from consistently decreasing daily averages', function () {
$station = Station::factory()->create();
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 16000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['trend']['direction'])->toBe('down')
->and($result['predicted_direction'])->toBe('down')
->and($result['action'])->toBe('wait');
});
it('returns current_avg from station_prices_current', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14750,
]);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['current_avg'])->toBe(147.5);
});
it('includes all required keys in response', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result)
->toHaveKeys([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'weekly_summary', 'signals',
])
->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
])
->and($result['weekly_summary'])->toHaveKeys([
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
'yesterday_today_delta_pence', 'last_7_days_series',
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
]);
});
it('weekly_summary returns null prices and empty series when there is no data', function () {
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBeNull()
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
->and($weekly['last_7_days_series'])->toBe([])
->and($weekly['cheapest_day'])->toBeNull()
->and($weekly['priciest_day'])->toBeNull()
->and($weekly['is_regional'])->toBeFalse();
});
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo * 50),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBe(140.5)
->and($weekly['today_avg'])->toBe(140.0)
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
->and(count($weekly['last_7_days_series']))->toBe(7)
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
->and($weekly['priciest_day']['avg'])->toBe(143.0);
});
it('weekly_summary falls back from regional to national when regional data is empty', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Coordinates 600+ km away from any station — no regional data available.
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
$weekly = $result['weekly_summary'];
expect($weekly['is_regional'])->toBeFalse()
->and(count($weekly['last_7_days_series']))->toBe(7);
});
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['weekly_summary']['is_regional'])->toBeTrue();
});
it('always returns e10 as fuel_type', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['fuel_type'])->toBe('e10');
});
it('returns national region_key without coordinates', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['region_key'])->toBe('national');
});
it('returns regional region_key when coordinates are provided', function () {
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['region_key'])->toBe('regional');
});
it('enables regional_momentum signal when coordinates are provided', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['signals']['regional_momentum']['enabled'])->toBeTrue();
});
it('disables regional_momentum signal without coordinates', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['regional_momentum']['enabled'])->toBeFalse();
});
it('disables trend signal when r_squared is below 0.5', function () {
$station = Station::factory()->create();
// Highly erratic prices (zigzag pattern) — low R²
$prices = [14000, 16000, 13000, 17000, 12000, 18000, 14500];
foreach ($prices as $daysAgo => $price) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => $price,
'price_effective_at' => now()->subDays(count($prices) - 1 - $daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
expect($result['signals']['trend']['data_points'])->toBeInt();
});
it('oil signal is disabled when no price_predictions row covers today or later', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['oil']['enabled'])->toBeFalse();
});
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'flat',
'confidence' => 60,
'reasoning' => null,
'generated_at' => now()->subHour(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 75,
'reasoning' => 'OPEC cut',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['enabled'])->toBeTrue()
->and($oil['direction'])->toBe('up')
->and($oil['score'])->toBe(1.0)
->and($oil['confidence'])->toBe(0.75);
});
it('oil signal prefers llm_with_context over plain llm', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 70,
'reasoning' => 'baseline',
'generated_at' => now(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm_with_context',
'direction' => 'rising',
'confidence' => 82,
'reasoning' => 'with context',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['direction'])->toBe('up')
->and($oil['confidence'])->toBe(0.82);
});
it('confidence reaches "high" when trend and oil agree strongly', function () {
$station = Station::factory()->create();
// Strong falling trend over 7 days, ~1p/day
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 80,
'reasoning' => 'agree',
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('down')
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
->and($result['confidence_label'])->toBe('high');
});
it('confidence drops when trend and oil disagree', function () {
$station = Station::factory()->create();
// Strong falling trend
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Oil disagrees: rising
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 80,
'reasoning' => 'opec',
'generated_at' => now(),
]);
$agree = app(NationalFuelPredictionService::class)->predict();
// Replace oil with one that agrees instead — confidence should be higher
DB::table('price_predictions')->update([
'direction' => 'falling',
]);
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
});
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
$station = Station::factory()->create();
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo % 7) * 50,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
});
it('reasoning fallback for the wait action does not say "fill up"', function () {
// No data → trend disabled, brand disabled, oil disabled.
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'falling',
'confidence' => 50,
'reasoning' => null,
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
if ($result['action'] === 'wait') {
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
} else {
// If thresholds keep this at no_signal, still verify action-aware fallback exists
expect($result['reasoning'])->toBeString();
}
});