# Scoring Engine (AlertScoringService) ## Purpose Produces a "fill up now or wait?" recommendation per user based on their local station history. Output is one of: `fill_up`, `wait`, `no_signal`. Never guess — stay silent (no_signal) when signals conflict or data is insufficient. ## The 5 signals (in priority order) ### Signal 1 — Local price trend (HIGHEST WEIGHT) - Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng) - Use last 14 days of history for `e10` (or user's preferred fuel type) - **Use linear regression, not rolling averages:** - Run least-squares regression on `(recorded_at, price_pence)` pairs - Calculate slope (pence/day) and R² (goodness of fit, 0–1) - Only use the regression result if R² ≥ 0.5 — below that, data is too noisy - Use adaptive lookback: try 5 days first (best signal on sharp moves), fall back to 14 days if R² < 0.5 - **Falling**: slope ≤ -0.3p/day AND R² ≥ 0.5 → wait signal, points scale with slope magnitude - **Rising**: slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up signal - **Flat / noisy**: |slope| < 0.3 OR R² < 0.5 → no signal from this source - Store slope, R², lookback_days, and data_points in signal output - Weight: 40 points max ### Signal 2 — Supermarket anchor effect (HIGH WEIGHT) - Find nearest supermarket station (is_supermarket = 1) within 10km - Check if supermarket cut price in last 48 hours (> 1p drop) - Check if nearest non-supermarket stations have NOT yet followed - If supermarket cut AND independents haven't moved → strong wait signal - Also check the inverse: if supermarket RAISED and independents haven't → mild fill_up - Weight: 35 points max ### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data) - Per station: average price by day-of-week over last 90 days - Only activate if station has 56+ days of history - If today is statistically 1.5p+ cheaper than weekly average → mild fill_up - If today is statistically 1.5p+ more expensive → mild wait - Weight: 15 points max ### Signal 4 — Brent crude direction (LOW WEIGHT) - Read from `price_predictions` table — never query `brent_prices` directly in scoring - `OilPriceService::generatePrediction()` runs daily at 7am and writes the prediction - LLM (`source = 'llm'`) is preferred; EWMA (`source = 'ewma'`) is the fallback - Direction `rising` → mild fill_up pressure; `falling` → mild wait; `flat` → no signal - Points awarded proportionally to confidence: `(confidence / 100) * 10` - Weight: 10 points max ### Signal 5 — Price stickiness (CONFIDENCE MODIFIER) - Per station: calculate average hold duration (days between price changes) from history - Requires 30+ days of history to activate - Use as a confidence modifier, not a directional signal: - avg hold < 2 days → reduce overall confidence by 5 points (volatile, hard to predict) - avg hold 2–4 days → neutral, no adjustment - avg hold > 5 days → increase overall confidence by 5 points (predictable, sticky) - Store avg_hold_days and data_points in signal output - Applied after all other signals are summed (±5 points) ## Confidence thresholds - Score 70–100: strong signal → fire recommendation + notification - Score 40–69: weak signal → show in dashboard only, no push/SMS/WhatsApp - Score 0–39: no_signal → stay silent entirely **Only send notifications when confidence ≥ 70.** Never spam. ## Output (stored in scoring_results) ```php [ 'recommendation' => 'wait', 'confidence' => 78, 'signals' => [ 'trend' => [ 'direction' => 'falling', 'slope' => -1.07, // pence per day 'r_squared' => 0.96, 'lookback_days' => 5, 'data_points' => 5, 'points' => 32, ], 'supermarket' => ['triggered' => true, 'points' => 35], 'day_pattern' => ['triggered' => false, 'points' => 0], 'brent' => ['direction' => 'flat', 'points' => 0], 'stickiness' => ['avg_hold_days' => 2.8, 'modifier' => 0], ], 'local_avg_pence' => 14380, // 143.80p 'trend_delta' => -2.3, // pence change over lookback period ] ``` ## Human-readable reason strings Always generate a plain-English reason for the recommendation: - "Prices near you have been falling at 1.1p/day for 5 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours." - "Prices are rising sharply in your area (+7.5p expected this week) — filling up today avoids paying more later." - "No clear pattern this week — fill up at the cheapest station near you now." Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications. ## Data quality — anomaly rejection The Fuel Finder API contains dirty data (live example: 1369.0p/litre in national index). Reject a price record before storing or scoring if: - `price_pence > 25000` (over 250p/litre — physically implausible for UK pump prices) - `price_pence < 10000` (under 100p/litre — almost certainly a decimal entry error) - Price changed by more than 20p in a single update from the same station (flag for review, do not use in scoring) Log rejected records to an `anomalous_prices` table for monitoring. Never let a dirty data point skew the regression slope or collapse R². ## Accuracy self-tracking After 3 days, check if `wait` recommendation was correct (prices did fall further). Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area."