3.7 KiB
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_predictionstable only (never querybrent_pricesin 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)
[
'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.