diff --git a/app/Services/NationalFuelPredictionService.php b/app/Services/NationalFuelPredictionService.php index bbae383..72a95ca 100644 --- a/app/Services/NationalFuelPredictionService.php +++ b/app/Services/NationalFuelPredictionService.php @@ -4,23 +4,31 @@ 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 R_SQUARED_THRESHOLD = 0.5; - private const float SLOPE_THRESHOLD_PENCE = 0.3; - /** Slope (pence/day) at which trend score saturates to ±1.0. */ - private const float SLOPE_SATURATION_PENCE = 0.5; - - /** Minimum unique days of history for the day-of-week signal to activate. */ - private const int DAY_OF_WEEK_MIN_DAYS = 21; - 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, @@ -41,18 +49,17 @@ class NationalFuelPredictionService { $fuelType = FuelType::E10; $hasCoordinates = $lat !== null && $lng !== null; + $context = new SignalContext($fuelType, $lat, $lng); $currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng); - $trend = $this->computeTrendSignal($fuelType); - $dayOfWeek = $this->computeDayOfWeekSignal($fuelType); - $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); - $stickiness = $this->computeStickinessSignal($fuelType); - $oil = $this->computeOilSignal(); + $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 = $hasCoordinates - ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) - : $this->disabledSignal('No coordinates provided for regional momentum analysis'); + $regionalMomentum = $this->regionalMomentumSignal->compute($context); $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil'); @@ -121,369 +128,6 @@ class NationalFuelPredictionService return $avg !== null ? round((float) $avg / 100, 1) : 0.0; } - /** - * Linear regression on daily national average prices. - * Tries 5-day lookback first; falls back to 14-day if R² < threshold. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} - */ - private function computeTrendSignal(FuelType $fuelType): array - { - foreach ([5, 14] as $lookbackDays) { - $rows = DB::table('station_prices') - ->where('fuel_type', $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, - ]; - } - - /** - * Compare today's average price against the per-weekday average over 90 days. - * Requires 56+ days of history to activate. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} - */ - private function computeDayOfWeekSignal(FuelType $fuelType): array - { - $isSqlite = DB::connection()->getDriverName() === 'sqlite'; - $dowExpr = $isSqlite - ? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)" - : 'DAYOFWEEK(price_effective_at)'; - - $rows = DB::table('station_prices') - ->where('fuel_type', $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::DAY_OF_WEEK_MIN_DAYS) { - return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_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, - ]; - } - - /** - * Compare supermarket vs non-supermarket 7-day price trend. - * Detects divergence where one group has moved but the other hasn't yet. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} - */ - private function computeBrandBehaviourSignal(FuelType $fuelType): array - { - $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(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, - ]; - } - - /** - * Average hold duration (days between price changes) as a confidence modifier. - * Requires 30+ days of history. Returns a score between -0.1 and +0.1. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} - */ - private function computeStickinessSignal(FuelType $fuelType): array - { - $isSqlite = DB::connection()->getDriverName() === 'sqlite'; - $diffExpr = $isSqlite - ? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)' - : 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))'; - - $rows = DB::table('station_prices') - ->where('fuel_type', $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, - ]; - } - - /** - * Placeholder for regional momentum signal (requires lat/lng). - * Compares local station prices vs national average trend. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} - */ - private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array - { - // Regional momentum: compare trend of stations within 50km vs national trend - [$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(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'); - } - - $regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all()); - $direction = match (true) { - $regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up', - $regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down', - default => 'stable', - }; - - return [ - 'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7), - 'confidence' => min(1.0, $regionalRegression['r_squared']), - 'direction' => $direction, - 'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')', - 'data_points' => $rows->count(), - 'enabled' => true, - ]; - } - - /** - * Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback) - * covering today or later. Sourced from price_predictions, which OilPriceService - * populates daily. - * - * @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} - */ - private function computeOilSignal(): 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, - ]; - } - /** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ private function disabledSignal(string $detail): array { @@ -736,47 +380,6 @@ class NationalFuelPredictionService return [$series, $usedRegional]; } - /** - * Least-squares linear regression. - * x is the array index (day number), y is the price value. - * - * @param float[] $values - * @return array{slope: float, r_squared: float} - */ - private 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]; - } - /** * @param array{enabled: bool, detail: string, direction: string} $trend * @param array{enabled: bool, detail: string, direction: string} $brandBehaviour diff --git a/app/Services/Prediction/Signals/AbstractSignal.php b/app/Services/Prediction/Signals/AbstractSignal.php new file mode 100644 index 0000000..333db26 --- /dev/null +++ b/app/Services/Prediction/Signals/AbstractSignal.php @@ -0,0 +1,61 @@ + 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 new file mode 100644 index 0000000..4e31a99 --- /dev/null +++ b/app/Services/Prediction/Signals/BrandBehaviourSignal.php @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..8149c51 --- /dev/null +++ b/app/Services/Prediction/Signals/DayOfWeekSignal.php @@ -0,0 +1,83 @@ +getDriverName() === 'sqlite'; + $dowExpr = $isSqlite + ? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)" + : 'DAYOFWEEK(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, + ]; + } +} diff --git a/app/Services/Prediction/Signals/OilSignal.php b/app/Services/Prediction/Signals/OilSignal.php new file mode 100644 index 0000000..2e46889 --- /dev/null +++ b/app/Services/Prediction/Signals/OilSignal.php @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..39e6088 --- /dev/null +++ b/app/Services/Prediction/Signals/RegionalMomentumSignal.php @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..f5637f1 --- /dev/null +++ b/app/Services/Prediction/Signals/Signal.php @@ -0,0 +1,24 @@ +lat !== null && $this->lng !== null; + } +} diff --git a/app/Services/Prediction/Signals/StickinessSignal.php b/app/Services/Prediction/Signals/StickinessSignal.php new file mode 100644 index 0000000..89af42a --- /dev/null +++ b/app/Services/Prediction/Signals/StickinessSignal.php @@ -0,0 +1,53 @@ +getDriverName() === 'sqlite'; + $diffExpr = $isSqlite + ? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)' + : 'DATEDIFF(MAX(price_effective_at), MIN(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, + ]; + } +} diff --git a/app/Services/Prediction/Signals/TrendSignal.php b/app/Services/Prediction/Signals/TrendSignal.php new file mode 100644 index 0000000..6e33f24 --- /dev/null +++ b/app/Services/Prediction/Signals/TrendSignal.php @@ -0,0 +1,86 @@ +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, + ]; + } +}