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>
87 lines
3.3 KiB
PHP
87 lines
3.3 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|