refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal methods, a private linearRegression helper, and a private disabledSignal shape factory all crammed together. Each signal is now an independently testable class. - App\Services\Prediction\Signals\Signal — interface - App\Services\Prediction\Signals\SignalContext — input value object (FuelType + optional lat/lng + hasCoordinates() helper) - App\Services\Prediction\Signals\AbstractSignal — shared disabledSignal() and linearRegression() helpers - TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal, RegionalMomentumSignal, OilSignal — one class each, extending AbstractSignal NationalFuelPredictionService receives the 6 signal classes via constructor injection and orchestrates them. The lat/lng null-guard for regional momentum now lives inside RegionalMomentumSignal::compute() so the coordinator no longer branches on coordinate presence. Aggregation, weekly summary, and reasoning helpers stay in the service for now — they are coupled to the public predict() output shape and are candidates for a follow-up extraction once a stable API is locked in. Service: 803 → 414 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,23 +4,31 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Enums\FuelType;
|
use App\Enums\FuelType;
|
||||||
use App\Models\StationPriceCurrent;
|
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 Carbon\CarbonInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class NationalFuelPredictionService
|
class NationalFuelPredictionService
|
||||||
{
|
{
|
||||||
private const float R_SQUARED_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
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;
|
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{
|
* @return array{
|
||||||
* fuel_type: string,
|
* fuel_type: string,
|
||||||
@@ -41,18 +49,17 @@ class NationalFuelPredictionService
|
|||||||
{
|
{
|
||||||
$fuelType = FuelType::E10;
|
$fuelType = FuelType::E10;
|
||||||
$hasCoordinates = $lat !== null && $lng !== null;
|
$hasCoordinates = $lat !== null && $lng !== null;
|
||||||
|
$context = new SignalContext($fuelType, $lat, $lng);
|
||||||
|
|
||||||
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
||||||
$trend = $this->computeTrendSignal($fuelType);
|
$trend = $this->trendSignal->compute($context);
|
||||||
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
|
||||||
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
|
||||||
$stickiness = $this->computeStickinessSignal($fuelType);
|
$stickiness = $this->stickinessSignal->compute($context);
|
||||||
$oil = $this->computeOilSignal();
|
$oil = $this->oilSignal->compute($context);
|
||||||
|
|
||||||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||||
$regionalMomentum = $hasCoordinates
|
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
|
||||||
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
|
||||||
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
|
||||||
|
|
||||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
$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;
|
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} */
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||||||
private function disabledSignal(string $detail): array
|
private function disabledSignal(string $detail): array
|
||||||
{
|
{
|
||||||
@@ -736,47 +380,6 @@ class NationalFuelPredictionService
|
|||||||
return [$series, $usedRegional];
|
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} $trend
|
||||||
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
||||||
|
|||||||
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
83
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
$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', $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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/Signal.php
Normal file
24
app/Services/Prediction/Signals/Signal.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
53
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class StickinessSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
public function compute(SignalContext $context): 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', $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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?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