feat: add Laravel Fortify skill, condense API data rules, add homepage mockup
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Ovidiu U
2026-04-09 14:19:04 +01:00
parent 1848c070da
commit 19d5c6eb0b
7 changed files with 753 additions and 394 deletions

View File

@@ -34,344 +34,25 @@ Content-Type: application/json
- Use the `refresh_token` to regenerate before expiry if needed
- Include token in every API request: `Authorization: Bearer {token}`
Fuel Finder REST API
The Fuel Finder API is a REST API that gives a simple, consistent way to request, create and update data. REST stands for Representational State Transfer which is an architectural software style in which standard HTTP request methods are used to retrieve and modify representations of data. This is identical to the process of retrieving a web page or submitting a web form.
Representational State Transfer (REST) web services
In a RESTful API, each data resource has a unique URL and is manipulated using standard HTTP verbs such as:
GET to request a resource
POST to create a resource (not used for read-only endpoints)
PUT to change a resource (not used for read-only endpoints)
DELETE to remove a resource (not used for read-only endpoints)
Example: request a price resource
GET: https://api.fuelfinder.service.gov.uk/v1/prices/GB-12345 HTTP/1.1
The request uses GET and does not include a request body.
In a RESTful API, a resource is modified by POSTing a revised resource representation, in this case JSON, to the same resource URL:
POST: https://api.fuelfinder.service.gov.uk/v1/<endpoint>
Content-Type: text/json
{
"CustomerName": "Joe Bloggs",
"Address": "",
"etc": etc
}
REST builds on the features of HTTP. Because each resource has a globally unique URL and can be fetched with GET, REST APIs can benefit from existing network components such as caches and proxies.
The JSON data format
Responses use JSON (JavaScript Object Notation). JSON is a compact, widely used format for storing and exchanging data. Most programming languages support JSON, which makes it well suited to HTTP-based API services.
#### Endpoints
- Endpoints
- Method Endpoint
- GET Fetch all PFS fuel prices
- GET Fetch incremental PFS fuel prices
- GET Fetch PFS information
- GET Fetch incremental PFS information
- `GET /api/v1/pfs/fuel-prices?batch-number` — all/incremental station prices
- `GET /api/v1/pfs?batch-number` — all/incremental station metadata
**Fuel prices response fields** (array of stations):
- `node_id` — station identifier
- `trading_name` — station name
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
- Fuel types: `E5`, `E10`, `B7_STANDARD`, `B7_PREMIUM`, `B10`, `HVO`
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
```
https://www.fuel-finder.service.gov.uk/api/v1/pfs/fuel-prices?batch-number
[
{
"node_id": "0028acef5f3afc41c7e7d56fb285a940dfb64d6fea01cb4accd79c148321112d",
"public_phone_number": null,
"trading_name": "Alex Fuel Station",
"fuel_prices": [
{
"fuel_type": "E5",
"price": 159.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
},
{
"fuel_type": "E10",
"price": 132.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
},
{
"fuel_type": "B7_STANDARD",
"price": 141.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
}
]
},
{
"node_id": "01da92125c3751767044d06b202f45da5933f0e16e256fa3e98a16af8386308d",
"public_phone_number": "",
"trading_name": "Star Garage",
"fuel_prices": [
{
"fuel_type": "E5",
"price": 159.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
}
]
},
{
"node_id": "020592cd81196efdb61ab2135f837ddf3d2bee4e64346810270f0b088b4c09d8",
"public_phone_number": null,
"trading_name": "Blue Hills Fuel Station",
"fuel_prices": [
{
"fuel_type": "E5",
"price": 159.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
},
{
"fuel_type": "B7_STANDARD",
"price": 141.9,
"price_last_updated": "2026-02-17T16:03:04.938Z",
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
}
]
}
]
```
```
https://www.fuel-finder.service.gov.uk/api/v1/pfs?batch-number=1
[
{
"node_id": "9b275ab576eeba3c6677984be15ee22a74e54fdfe8e5ea700e84a03178dc4ac1",
"public_phone_number": null,
"trading_name": "TEST",
"is_same_trading_and_brand_name": true,
"brand_name": "TEST",
"temporary_closure": false,
"permanent_closure": false,
"permanent_closure_date": null,
"is_motorway_service_station": false,
"is_supermarket_service_station": false,
"location": {
"address_line_1": "HALL & WOODHOUSE, TAPLOW BOATYARD, MILL LANE, TAPLOW, MAIDENHEAD, SL6 0AA",
"address_line_2": null,
"city": "MAIDENHEAD",
"country": "England",
"county": null,
"postcode": "SL6 0AA",
"latitude": 51.5268585,
"longitude": -0.700361
},
"amenities": [
"water_filling"
],
"opening_times": {
"usual_days": {
"monday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"tuesday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"wednesday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"thursday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"friday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"saturday": {
"open": "00:00:00",
"close": "00:00:00",
"is_24_hours": false
},
"sunday": {
"open": "00:00:00",
"close": "23:59:00",
"is_24_hours": true
}
},
"bank_holiday": {
"type": "bank holiday",
"open_time": "00:00:00",
"close_time": "00:00:00",
"is_24_hours": false
}
},
"fuel_types": [
"E10",
"E5",
"HVO",
"B10"
]
},
{
"node_id": "4fd9a4c6b48358b9b5c95989fba100fdcbb87c9e909ed4ce1ad96f64ffb8b56a",
"public_phone_number": "+44 7723608248",
"trading_name": "TEST FORECOURT 1",
"is_same_trading_and_brand_name": true,
"brand_name": "TEXACO ONE",
"temporary_closure": false,
"permanent_closure": null,
"permanent_closure_date": null,
"is_motorway_service_station": false,
"is_supermarket_service_station": false,
"location": {
"address_line_1": "NEWPORT",
"address_line_2": "",
"city": "BROUGH",
"country": "ENGLAND",
"county": "EAST YORKSHIRE",
"postcode": "HU15 2RD",
"latitude": 51.258503,
"longitude": -3.417567
},
"amenities": [
"adblue_packaged",
"adblue_pumps",
"car_wash",
"customer_toilets"
],
"opening_times": {
"usual_days": {
"monday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"tuesday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"wednesday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"thursday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"friday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"saturday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
},
"sunday": {
"open": "06:00:01",
"close": "23:00:01",
"is_24_hours": false
}
},
"bank_holiday": {
"type": "standard",
"open_time": "06:00:01",
"close_time": "23:00:01",
"is_24_hours": false
}
},
"fuel_types": [
"B10"
]
},
{
"node_id": "91bdda1c07fa05110a31639cc66932f9ed8bd388d4f6be542a423365bcfd53e1",
"public_phone_number": "+442071930000",
"trading_name": "SUPERFUEL LOUGHBOROUGH 12",
"is_same_trading_and_brand_name": true,
"brand_name": "SUPERFUEL STATION 4",
"temporary_closure": false,
"permanent_closure": null,
"permanent_closure_date": null,
"is_motorway_service_station": false,
"is_supermarket_service_station": false,
"location": {
"address_line_1": "14 LONDON ROAD",
"address_line_2": "FUELVILLE",
"city": "LOUGHBOROUGH",
"country": "ENGLAND",
"county": "LEICESTERSHIRE",
"postcode": "LE11 9AA",
"latitude": 50.503343,
"longitude": -2.12444
},
"amenities": [
"adblue_packaged",
"adblue_pumps",
"car_wash",
"customer_toilets",
"water_filling"
],
"opening_times": {
"usual_days": {
"monday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"tuesday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"wednesday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"thursday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"friday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"saturday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
},
"sunday": {
"open": "06:00:00",
"close": "22:00:00",
"is_24_hours": false
}
},
"bank_holiday": {
"type": "standard",
"open_time": "08:00:00",
"close_time": "20:00:00",
"is_24_hours": false
}
},
"fuel_types": [
"E5",
"HVO",
"B10",
"B7_PREMIUM",
"B7_STANDARD"
]
}
]
```
**Station metadata response fields** (array of stations):
- `node_id`, `trading_name`, `brand_name`
- `is_supermarket_service_station`, `is_motorway_service_station`
- `temporary_closure`, `permanent_closure`
- `location``{address_line_1, city, postcode, latitude, longitude}`
- `amenities` — string array (e.g. `car_wash`, `adblue_pumps`)
- `fuel_types` — string array of available fuel types
- `opening_times` — per-day open/close times (not used in scoring)
### FuelPriceService responsibilities
1. Fetch OAuth token (cache it)

View File

@@ -8,52 +8,30 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
## 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, 01)
- 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 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 (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 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 (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 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 (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 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)
- 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 24 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)
### Signal 5 — Price stickiness (confidence modifier, ±5 pts)
- Requires 30+ days history. Applied after all signals are summed.
- avg hold < 2 days -5 pts; 24 days 0; > 5 days → +5 pts
- Store: avg_hold_days, data_points
## Confidence thresholds
@@ -99,18 +77,9 @@ Reason strings are stored in `scoring_results.signals` JSON and shown in the UI
## 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².
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` 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."
After 3 days, check if `wait` was correct (prices fell further). Store outcome in `scoring_results` for display.