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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 3–5 days.
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
|
||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||
and a one-sentence reasoning.
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -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 3–5 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 3–5 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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}))";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user