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:
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