# 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 (40 pts max) - Nearest 5 stations within 5km; user's preferred fuel type - Least-squares regression on `(recorded_at, price_pence)`; adaptive lookback: 5 days first, fall back to 14 if R² < 0.5 - Slope ≤ -0.3p/day AND R² ≥ 0.5 → wait; slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up; otherwise no signal - Store: slope, R², lookback_days, data_points ### Signal 2 — Supermarket anchor effect (35 pts max) - Nearest supermarket (is_supermarket = 1) within 10km - Supermarket cut > 1p in last 48h AND independents haven't followed → wait - Inverse (supermarket raised, independents haven't) → mild fill_up ### Signal 3 — Day-of-week pattern (15 pts max) - Requires 56+ days of station history; average price by day-of-week over last 90 days - Today 1.5p+ below weekly average → mild fill_up; 1.5p+ above → mild wait ### Signal 4 — Brent crude direction (10 pts max) - Read from `price_predictions` table only (never query `brent_prices` in scoring) - LLM (`source='llm'`) preferred; EWMA fallback. Points = `(confidence / 100) * 10` - `rising` → fill_up; `falling` → wait; `flat` → no signal ### Signal 5 — Price stickiness (confidence modifier, ±5 pts) - Requires 30+ days history. Applied after all signals are summed. - avg hold < 2 days → -5 pts; 2–4 days → 0; > 5 days → +5 pts - Store: avg_hold_days, data_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 Reject before storing or scoring: `price_pence > 25000` or `< 10000`, or single-update change > 20p (flag for review). Log to `anomalous_prices` table. Never let dirty data skew regression slope or collapse R². ## Accuracy self-tracking After 3 days, check if `wait` was correct (prices fell further). Store outcome in `scoring_results` for display.