User management resource with editable is_admin field, postcode support, admin filter, and inline delete action. Includes list and edit pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.5 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 (HIGHEST WEIGHT)
- Query
station_pricesfor 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
- Run least-squares regression on
- 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_predictionstable — never querybrent_pricesdirectly 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)
[
'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."