diff --git a/app/Console/Commands/PredictOilPrices.php b/app/Console/Commands/PredictOilPrices.php deleted file mode 100644 index ea74ed2..0000000 --- a/app/Console/Commands/PredictOilPrices.php +++ /dev/null @@ -1,58 +0,0 @@ -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; - } -} diff --git a/app/Filament/Resources/OilPredictionResource.php b/app/Filament/Resources/OilPredictionResource.php deleted file mode 100644 index e302476..0000000 --- a/app/Filament/Resources/OilPredictionResource.php +++ /dev/null @@ -1,141 +0,0 @@ -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}'), - ]; - } -} diff --git a/app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php b/app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php deleted file mode 100644 index 1674407..0000000 --- a/app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php +++ /dev/null @@ -1,42 +0,0 @@ -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(); - } - }), - ]; - } -} diff --git a/app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php b/app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php deleted file mode 100644 index 03eef5d..0000000 --- a/app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php +++ /dev/null @@ -1,16 +0,0 @@ -usersStat(), $this->searchesStat(), $this->stationsStat(), - $this->oilPredictionStat(), + $this->weeklyForecastStat(), $this->apiErrorsStat(), ]; } @@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget ->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) { - return Stat::make('Latest oil prediction', 'None') + if ($forecast === null) { + return Stat::make('Latest weekly forecast', 'None') ->icon('heroicon-o-beaker') ->color('gray'); } - $ageHours = $prediction->generated_at->diffInHours(now()); - $color = $ageHours > 24 ? 'warning' : 'success'; - $value = $prediction->direction->label().' · '.$prediction->confidence.'%'; + $ageHours = $forecast->generated_at->diffInHours(now()); + $color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week + $directionLabel = ucfirst($forecast->direction); + $value = $directionLabel.' · '.$forecast->ridge_confidence.'%'; - return Stat::make('Latest oil prediction', $value) - ->description('Generated '.$prediction->generated_at->diffForHumans()) - ->url(route('filament.admin.resources.oil-predictions.index')) + return Stat::make('Latest weekly forecast', $value) + ->description('For week of '.$forecast->forecast_for->toDateString()) ->icon('heroicon-o-beaker') ->color($color); } diff --git a/app/Models/PricePrediction.php b/app/Models/PricePrediction.php deleted file mode 100644 index d182777..0000000 --- a/app/Models/PricePrediction.php +++ /dev/null @@ -1,54 +0,0 @@ - */ - 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 $query - * @return Builder - */ - 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'); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4279bbf..b59a8e0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,6 @@ namespace App\Providers; use App\Listeners\HandleStripeWebhook; 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 Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -25,15 +20,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->bind(OilPredictionProvider::class, function ($app) { - $logger = $app->make(ApiLogger::class); - - return match (config('services.llm.provider')) { - 'openai' => new OpenAiPredictionProvider($logger), - 'gemini' => new GeminiPredictionProvider($logger), - default => new AnthropicPredictionProvider($logger), - }; - }); + // No bindings here. The legacy LLM prediction provider binding + // was removed when the Phase 4 ridge model + Phase 8 + // LlmOverlayService replaced the old daily oil prediction. } /** diff --git a/app/Services/BrentPricePredictor.php b/app/Services/BrentPricePredictor.php deleted file mode 100644 index b50338d..0000000 --- a/app/Services/BrentPricePredictor.php +++ /dev/null @@ -1,119 +0,0 @@ -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)); - } -} diff --git a/app/Services/Ewma.php b/app/Services/Ewma.php deleted file mode 100644 index 1dd226a..0000000 --- a/app/Services/Ewma.php +++ /dev/null @@ -1,25 +0,0 @@ -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 $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 <<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 << */ - private function headers(): array - { - return [ - 'x-api-key' => $this->apiKey(), - 'anthropic-version' => '2023-06-01', - ]; - } - - /** @return array{name: string, description: string, input_schema: array} */ - 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 $content */ - private function extractToolInput(array $content): ?array - { - $block = collect($content)->firstWhere('type', 'tool_use'); - - return $block['input'] ?? null; - } -} diff --git a/app/Services/LlmPrediction/GeminiPredictionProvider.php b/app/Services/LlmPrediction/GeminiPredictionProvider.php deleted file mode 100644 index 686c692..0000000 --- a/app/Services/LlmPrediction/GeminiPredictionProvider.php +++ /dev/null @@ -1,60 +0,0 @@ -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; - } -} diff --git a/app/Services/LlmPrediction/OilPredictionProvider.php b/app/Services/LlmPrediction/OilPredictionProvider.php deleted file mode 100644 index 9c756fc..0000000 --- a/app/Services/LlmPrediction/OilPredictionProvider.php +++ /dev/null @@ -1,18 +0,0 @@ - $prices Chronological Brent crude prices - */ - public function predict(Collection $prices): ?PricePrediction; -} diff --git a/app/Services/LlmPrediction/OpenAiPredictionProvider.php b/app/Services/LlmPrediction/OpenAiPredictionProvider.php deleted file mode 100644 index ec69d1f..0000000 --- a/app/Services/LlmPrediction/OpenAiPredictionProvider.php +++ /dev/null @@ -1,62 +0,0 @@ -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; - } -} diff --git a/app/Services/NationalFuelPredictionService.php b/app/Services/NationalFuelPredictionService.php deleted file mode 100644 index 72a95ca..0000000 --- a/app/Services/NationalFuelPredictionService.php +++ /dev/null @@ -1,414 +0,0 @@ -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 $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 $signals - * @param array $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, - * 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, 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); - } -} diff --git a/app/Services/Prediction/Signals/AbstractSignal.php b/app/Services/Prediction/Signals/AbstractSignal.php deleted file mode 100644 index 333db26..0000000 --- a/app/Services/Prediction/Signals/AbstractSignal.php +++ /dev/null @@ -1,61 +0,0 @@ - 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]; - } -} diff --git a/app/Services/Prediction/Signals/BrandBehaviourSignal.php b/app/Services/Prediction/Signals/BrandBehaviourSignal.php deleted file mode 100644 index 4e31a99..0000000 --- a/app/Services/Prediction/Signals/BrandBehaviourSignal.php +++ /dev/null @@ -1,61 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Prediction/Signals/DayOfWeekSignal.php b/app/Services/Prediction/Signals/DayOfWeekSignal.php deleted file mode 100644 index f3b464e..0000000 --- a/app/Services/Prediction/Signals/DayOfWeekSignal.php +++ /dev/null @@ -1,80 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Prediction/Signals/DbDialect.php b/app/Services/Prediction/Signals/DbDialect.php deleted file mode 100644 index 40cd53c..0000000 --- a/app/Services/Prediction/Signals/DbDialect.php +++ /dev/null @@ -1,40 +0,0 @@ -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}))"; - } -} diff --git a/app/Services/Prediction/Signals/OilSignal.php b/app/Services/Prediction/Signals/OilSignal.php deleted file mode 100644 index 2e46889..0000000 --- a/app/Services/Prediction/Signals/OilSignal.php +++ /dev/null @@ -1,63 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Prediction/Signals/RegionalMomentumSignal.php b/app/Services/Prediction/Signals/RegionalMomentumSignal.php deleted file mode 100644 index 39e6088..0000000 --- a/app/Services/Prediction/Signals/RegionalMomentumSignal.php +++ /dev/null @@ -1,52 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Prediction/Signals/Signal.php b/app/Services/Prediction/Signals/Signal.php deleted file mode 100644 index f5637f1..0000000 --- a/app/Services/Prediction/Signals/Signal.php +++ /dev/null @@ -1,24 +0,0 @@ -lat !== null && $this->lng !== null; - } -} diff --git a/app/Services/Prediction/Signals/StickinessSignal.php b/app/Services/Prediction/Signals/StickinessSignal.php deleted file mode 100644 index e463d84..0000000 --- a/app/Services/Prediction/Signals/StickinessSignal.php +++ /dev/null @@ -1,50 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Prediction/Signals/TrendSignal.php b/app/Services/Prediction/Signals/TrendSignal.php deleted file mode 100644 index 6e33f24..0000000 --- a/app/Services/Prediction/Signals/TrendSignal.php +++ /dev/null @@ -1,86 +0,0 @@ -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, - ]; - } -} diff --git a/database/factories/PricePredictionFactory.php b/database/factories/PricePredictionFactory.php deleted file mode 100644 index 5e70eba..0000000 --- a/database/factories/PricePredictionFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - */ -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]); - } -} diff --git a/database/migrations/2026_05_03_071205_drop_price_predictions_table.php b/database/migrations/2026_05_03_071205_drop_price_predictions_table.php new file mode 100644 index 0000000..71aed2e --- /dev/null +++ b/database/migrations/2026_05_03_071205_drop_price_predictions_table.php @@ -0,0 +1,30 @@ +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'); -}); diff --git a/tests/Feature/Admin/StatsOverviewWidgetTest.php b/tests/Feature/Admin/StatsOverviewWidgetTest.php index 8195fb2..af0dfed 100644 --- a/tests/Feature/Admin/StatsOverviewWidgetTest.php +++ b/tests/Feature/Admin/StatsOverviewWidgetTest.php @@ -2,10 +2,11 @@ use App\Filament\Widgets\StatsOverviewWidget; use App\Models\ApiLog; -use App\Models\PricePrediction; use App\Models\Station; use App\Models\User; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -18,7 +19,20 @@ beforeEach(function () { it('renders the stats overview widget', function () { User::factory()->count(3)->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)]); Livewire::test(StatsOverviewWidget::class) diff --git a/tests/Unit/Services/BrentPricePredictorTest.php b/tests/Unit/Services/BrentPricePredictorTest.php deleted file mode 100644 index 6c8a228..0000000 --- a/tests/Unit/Services/BrentPricePredictorTest.php +++ /dev/null @@ -1,145 +0,0 @@ -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() - ); -} diff --git a/tests/Unit/Services/LlmPrediction/AnthropicPredictionProviderTest.php b/tests/Unit/Services/LlmPrediction/AnthropicPredictionProviderTest.php deleted file mode 100644 index d7bb491..0000000 --- a/tests/Unit/Services/LlmPrediction/AnthropicPredictionProviderTest.php +++ /dev/null @@ -1,219 +0,0 @@ - '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, - ])); -} diff --git a/tests/Unit/Services/NationalFuelPredictionServiceTest.php b/tests/Unit/Services/NationalFuelPredictionServiceTest.php deleted file mode 100644 index f7ece32..0000000 --- a/tests/Unit/Services/NationalFuelPredictionServiceTest.php +++ /dev/null @@ -1,394 +0,0 @@ -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(); - } -});