19 Commits

Author SHA1 Message Date
Ovidiu U
8fe3461adf Update legal pages with ICO registration, query logging, push notifications, and automated recommendations disclosure
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
- Add ICO registration reference (00014395133) to privacy policy, terms of service, and refund policy
- Document search/query logging (hashed IP, location, fuel type, result count) with 24-month retention under legitimate interests
- Add push notification data section (OneSignal endpoints, encryption keys, preferences) to privacy policy
- Add new section on automated recommendations explaining fill-up timing algorithm is informational only without legal effects
- Clarify IP address collection context: security/abuse/fraud only, not individual profiling
- Update retention periods: security logs 12 months, query logs 24 months, push subscriptions until unsubscribe
- Expand data processor descriptions (OneSignal push data, Umami cookieless commitment)
- Add commercial use restrictions to terms: no scraping, mirroring, or republishing compiled data/rankings without permission
- Clarify downstream data aggregator role and upstream data feed limitations
- Add prohibition on using service while operating motor vehicle
- Remove annual billing references (monthly only)
- Add VAT status notice and downgrade-to-free-tier behavior on cancellation
- Add data controller contact details to privacy policy footer
2026-06-10 13:15:43 +01:00
Ovidiu U
ad2230728c Disable Daily and Smart pricing CTAs until features ship
Replace the basic/plus checkout links on /pricing with disabled
"Coming soon" buttons, gated by a COMING_SOON list so each tier's CTA
goes live again by removing it from that list. Free is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:15:16 +01:00
Ovidiu U
5bd909d227 Show site footer on dashboard pages and trim footer links
Render the shared SiteFooter in DashboardLayout so it appears on every
dashboard page (overview, saved stations, preferences, settings) from a
single source. Content area grows to pin the footer to the bottom on
short pages.

Trim the footer Product column to real anchors and drop the dead-link
Resources column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:40:51 +01:00
Ovidiu U
a841a7c900 Add dedicated /pricing page and lock launch tiers to Free/Daily/Smart
Consolidate pricing onto a single source. Pro is deferred from launch
(left dormant: no Stripe price, no card), so the offered set is 3 tiers.

- Extract the pricing grid and footer into shared components
  (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route
  rendering Pricing.vue; remove the pricing section from Home
- Repoint every upgrade link to the /pricing route (LandingNav and
  SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors
- Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory),
  update PlanFeaturesTest assertion
- Rewrite /pricing card bullets to match real entitlements (drop
  unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor)
- Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md
- Delete unused resources/views/components/pricing-card.blade.php

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:22:19 +01:00
Ovidiu U
adf0b93a45 Coarsen GPS coordinates to 3 dp before API/URL and deduplicate runSearch triggers
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
- HeroSearch: round lat/lng to 3 decimal places (~111m precision) before emit to prevent exact location leakage in shareable URLs and server logs
- Home: move duplicate-search guard into runSearch (single choke point) instead of watcher, eliminating race between route.query sync and direct onSearch call
- Add inline documentation referencing .claude/rules/frontend.md privacy guidance
2026-06-10 11:08:55 +01:00
Ovidiu U
1ae2f1396d Update legal pages with contact details, location handling, and alert channels
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
- Expand privacy policy with detailed location-use explanations: search-only, shareable links, and saved location
2026-06-10 10:55:54 +01:00
Ovidiu U
df4ebdb7c6 Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
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
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
2026-06-10 10:55:34 +01:00
Ovidiu U
7a8bd5c86a Remove placeholders and finalize contact details for launch
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
- Replace all `[PLACEHOLDER]` entries across legal pages with actual values
- Update privacy policy with ICO registration status and service provider details (Ionos, Vonage, OneSignal)
- Finalize contact email as `hello@fuel-alert.co.uk` throughout legal pages and footer
- Update legal layout to use full landing nav with auth/guest actions
- Add Umami analytics script to main app layout
- Remove "Fleet" nav link from landing navigation
- Add feature test to enforce no placeholders and correct contact domain remain in legal pages
2026-06-10 10:07:02 +01:00
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
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
- Add Cookie Policy view documenting essential cookies (session, CSRF, remember_me, fa_location) and cookieless Umami analytics
- Add Privacy Policy view covering UK GDPR compliance, data categories, lawful bases, processors, retention, and user rights
- Add Refund & Cancellation Policy view explaining 14-day cooling-off period under Consumer Contracts Regulations 2013 and express-consent flow
- Add Terms of Service view defining account rules, subscription billing, and governing law
- Create shared legal layout component with FuelAlert header, footer with cross-links, and consistent typography
- Add feature tests covering all four legal pages and their cross-links
- All policies include placeholders for ICO registration number, email, and hosting/email providers pending production config
2026-05-14 17:43:53 +01:00
Ovidiu U
598ef04645 Rebrand from "Fuel Price" to "Fuel Alert" and update project metadata
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
- Rename project in package.json, composer.json, and .env.example
- Update app name, URLs, and session domains to fuel-alert
- Comment out testimonials section in Home.vue
- Revise footer copy: remove "
2026-05-14 14:30:02 +01:00
Ovidiu U
07e0789044 fix(forecasting): persist LLM overlay under Tier-1 ITPM via two-call architecture
The daily forecast:llm-overlay command was being skipped because the previous
single-conversation flow consumed more than Tier-1's 50,000 input-tokens-per-
minute Anthropic bucket. The web_search tool auto-caches its results (~55k
tokens) and requires `encrypted_content` intact when those blocks are resent,
so the prior retry-on-missing-citations path either 429'd or 400'd on the
second call.

LlmOverlayService now runs two independent API calls. Phase 1 invokes the
web_search tool and we discard the transcript after harvesting the URLs +
titles from the returned web_search_tool_result blocks. Phase 2 is a fresh
conversation containing the forecast context and the harvested headlines as
plain text, with a forced submit_overlay tool call. events_cited is now
optional in the tool schema — Haiku's flaky compliance no longer matters
because citations come from the search results, not the model's transcription.
Model-tagged events (with directional impact) merge with harvested-only
entries (impact: 'neutral'), deduped by URL.

Between phases the service reads anthropic-ratelimit-input-tokens-remaining /
…-reset from Phase 1's headers and sleeps proportionally — only long enough
for the SUBMIT_TOKEN_BUDGET worth of refill, not for the full bucket reset,
capped at 65 seconds.

ApiLogger now captures usage.input_tokens, usage.output_tokens,
cache_read_input_tokens, cache_creation_input_tokens, plus the rate-limit
remaining/reset headers on every Anthropic response. New nullable columns on
api_logs make rate-limit diagnostics directly queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:22:42 +01:00
Ovidiu U
97e27fc057 feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
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
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection

fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries

- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
  in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
2026-05-14 13:23:52 +01:00
Ovidiu U
11a3b433ff feat(ui): consolidate map filters and rework station selection
- replace inline filter pills with a single "Filters" popover containing
  small pill buttons for fuel/radius/sort/brand (no native <select>s)
- map polish: Carto Positron tiles, hidden zoom buttons, locate-me floating
  button + accuracy ring, smooth flyTo transitions, slim ⓘ attribution
- map markers no longer open Leaflet popups; clicking a marker selects the
  station and surfaces the existing StationCard inline over the map, with
  swipe-down-to-close and a small overlay × button
- price colour now reflects deal quality (cheap / average / expensive vs
  search avg ± 3p) on both list and map — stable across sort/filter
- promote the "X ago" timestamp into the card header so it stays visible
  in the expanded state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:16:13 +01:00
Ovidiu U
8dad223d06 feat(admin): add Filament resources for the forecasting stack
Adds three resources under a new "Forecasting" navigation group: a full-CRUD
WatchedEventResource for the Layer 5 volatility detector, plus read-only
WeeklyForecastResource and BacktestResource so the ridge model output and
its calibration can be inspected without SQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:13:05 +01:00
Ovidiu U
1c46667f56 feat(api-logs): persist response body on failed external calls
ApiLogger now stores the upstream response body to
`api_logs.response_body` whenever the call failed (non-2xx response or
a RequestException carrying a response). Successful 2xx responses
remain null so the table stays small on busy services like fuel:poll
and oil:fetch.

Truncated at 64 KB. The column is mediumText so a future cap raise
needs no schema change.

Captures:
- 4xx and 5xx response bodies verbatim
- Body extracted from RequestException via `$e->response->body()`
  when callers use `Http::throw()`

Does not capture:
- ConnectionException (no response existed)
- Generic Throwable from the closure (same reason)

Motivation: the LLM overlay's "skipped — no verified citations" path
left no forensic trail to debug. With this, the next time anything
routed through ApiLogger fails — Anthropic 429s, FRED 5xx, Fuel
Finder errors — the failed body is queryable directly:

    SELECT response_body FROM api_logs
    WHERE service = ? AND status_code >= 400
    ORDER BY id DESC LIMIT 1;

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:51:18 +01:00
Ovidiu U
203200acb9 chore: retire legacy oil prediction pipeline
Removes everything that was made redundant by the new forecasting
stack. Per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md,
this was the cleanup planned at the end of Phase 4.

Deleted services and code:
- App\Services\Prediction\Signals\* (the old six-signal aggregator —
  trend, supermarket, day-of-week, brand-behaviour, stickiness,
  regional-momentum, oil — replaced by RidgeRegressionModel).
- App\Services\NationalFuelPredictionService (the post-Phase-4 thin
  shim; StationSearchService now depends on WeeklyForecastService
  directly, set up in the previous commit).
- App\Services\LlmPrediction\* (AbstractLlmPredictionProvider plus
  the four provider implementations — Anthropic, OpenAI, Gemini, and
  the OilPredictionProvider router. Replaced by LlmOverlayService).
- App\Services\BrentPricePredictor and App\Services\Ewma. The Ewma
  helper had no callers left after BrentPricePredictor went.
- App\Models\PricePrediction and its factory.
- App\Console\Commands\PredictOilPrices (the oil:predict command).
- App\Filament\Resources\OilPredictionResource and its Pages.

Schema and dashboard:
- Drop the price_predictions table via a new migration.
- Repoint the Filament StatsOverviewWidget tile from PricePrediction
  to WeeklyForecast so the dashboard reflects the new pipeline.
- Remove the OilPredictionProvider binding from AppServiceProvider.

Test cleanup:
- Delete tests for every retired service.
- Update StatsOverviewWidgetTest to seed weekly_forecasts instead of
  price_predictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:28 +01:00
Ovidiu U
ddd591ad47 feat(forecasting): build calibrated weekly forecast stack with LLM overlay and volatility detector
Replaces the implementation behind NationalFuelPredictionService — the
public JSON contract on /api/stations is preserved, but the engine is
new and honest.

Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md):
1. Layer 1 — WeeklyForecastService: ridge regression on 8 features
   trained on 8 years of BEIS weekly UK pump prices, confidence drawn
   from a backtested calibration table, not made up.
2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over
   station_prices_current. Never speaks about the future.
3. Layer 3 — verdict via rule gates, not confidence multipliers. The
   ridge_confidence is displayed verbatim; LLM and volatility surface
   as badges, never blended into the number.
4. Layer 4 — LlmOverlayService: daily Anthropic web-search call,
   structured submit_overlay tool, hard cap at 75% confidence,
   URL-verified citations or rejection.
5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the
   active flag, OR-combined triggers (Brent move >3%, LLM major
   impact, station churn (gated), watched_events).

Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the
8x8 normal-equation matrix. No external ML dependency. Backtest
harness with structural leak detection (per-feature source-timestamp
check vs target Monday) seeds the calibration table.

Backtest gate (62–68% directional accuracy on the 130-week hold-out)
ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change
baseline by ~30pp on real data.

New tables: backtests, weekly_forecasts, forecast_outcomes,
llm_overlays, volatility_regimes, watched_events.

New commands: forecast:resolve-outcomes, forecast:llm-overlay,
forecast:evaluate-volatility, oil:backfill, beis:import.

Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK,
forecast:evaluate-volatility hourly, beis:import Mon 09:30,
forecast:resolve-outcomes Mon 10:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:05 +01:00
Ovidiu U
d13a29df01 docs: amend prediction rebuild spec with implementation defaults and changelog v3
Adds two sections to the spec:
- Implementation defaults: pins the four open decisions settled before
  Phase 1 (naive baseline = zero-change, math = inline pure PHP,
  coefficients on the backtests row, BEIS retrain = manual CSV + cron)
  plus the namespace, scaler, and Pest conventions.
- Changelog v3: records the verdict-via-rule-gates architecture (gates
  not multipliers), removal of weeks_since_duty_change as a feature,
  lower 62% backtest gate, structural leak detector promoted to primary.

Captured here so a future session can resume implementation without
re-deriving them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:26 +01:00
Ovidiu U
c2c237a1b3 chore: stop tracking .DS_Store
Add .DS_Store to .gitignore and untrack the two committed copies
(.DS_Store at the root and .claude/.DS_Store). macOS noise that
shouldn't have been versioned in the first place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:16 +01:00
173 changed files with 10244 additions and 3842 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored

Binary file not shown.

View File

@@ -1,57 +1,118 @@
# Architecture # Architecture
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
backend exclusively over a **REST API** (`routes/api.php`
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
server-rendered legal pages, transactional emails, and the Filament admin panel.
```
Browser
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
└── REST API (routes/api.php → Http/Controllers/Api/*)
└── Services (app/Services/*) ← all business logic
└── Models / Jobs / Events / Notifications
```
## Core principle: fat Services, thin everything else ## Core principle: fat Services, thin everything else
All business logic lives in Service classes. Controllers, Livewire components, All business logic lives in Service classes. API controllers, console commands,
and console commands are thin orchestrators — they call Services and return results. jobs, listeners, and Filament resources are thin orchestrators — they call
This keeps the app API-extractable later without a rewrite. Services and return results. Never put domain logic in a controller or a Vue
component.
## Directory structure ## Directory structure (actual)
``` ```
app/ app/
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine) ├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
├── Http/Controllers/ # Minimal — auth + Stripe webhook only │ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
├── Livewire/ # Classic two-file Livewire components # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
├── Http/
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
│ ├── Requests/ # Form requests
│ └── Resources/Api/ # StationResource (API output shaping)
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
├── Services/ # ALL business logic — see below
├── Models/ # Eloquent models ├── Models/ # Eloquent models
├── Notifications/ # Laravel Notification classes (multi-channel) ├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
├── Services/ # ALL business logic lives here ├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
├── FuelPriceService.php # Fuel Finder API polling + storage # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
│ ├── AlertScoringService.php # Fill-up timing recommendation engine ├── Events/ # PricesUpdatedEvent
│ ├── StationTaggingService.php # Supermarket brand detection ├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
│ ├── NotificationDispatchService.php # Tier-aware notification routing ├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
│ ├── SubscriptionService.php # Cashier/tier helpers ├── Enums/ # FuelType, PlanTier, …
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng └── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
│ ├── LocationResult.php # DTO returned by PostcodeService
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
└── Jobs/ # Queued jobs (dispatch notifications per user)
resources/views/ app/Services/
├── livewire/ # Livewire Blade templates ├── FuelPriceService.php # Fuel Finder API polling + storage
── emails/ # Mailable templates ── StationTaggingService.php # Supermarket brand detection
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
├── HaversineQuery.php # Distance SQL helper
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
# LlmOverlayService, BacktestRunner, OutcomeResolver,
# VolatilityRegimeService, feature/leak tooling, …
resources/
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
└── views/
├── app.blade.php # SPA shell (mounts #app)
├── legal/ # Server-rendered legal pages
├── emails/ # Mailable templates
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
routes/ routes/
├── web.php # All web routes (Livewire pages) ├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
└── api.php # Empty for now — API added later if needed └── api.php # REST API consumed by the SPA (see below)
``` ```
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
> `prediction.md`, `notifications.md`) still name services that have since been
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
> `NationalFuelPredictionService`, `NotificationDispatchService`,
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
> against `app/Services/` before trusting it from those files.
## Service class conventions ## Service class conventions
- Constructor injection only — no facade usage inside Services - Constructor injection only — no facade usage inside Services
- Services are bound in AppServiceProvider if they need interfaces
- Each Service has one responsibility — do not merge concerns - Each Service has one responsibility — do not merge concerns
- Return typed DTOs or Eloquent collections — never raw arrays from Services - Return typed DTOs or Eloquent collections — never raw arrays from Services
- Services never dispatch jobs directly — that's the controller/command's job - Services never dispatch jobs directly — that's the controller/command/listener's job
## No API yet ## The REST API
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum `routes/api.php` is the SPA's backend. Three access tiers:
token auth. The app is Livewire-only until subscriber count justifies a native app.
When the API is added later, it will reuse the same Service classes.
## Livewire components (classic only) - **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
`GET /api/stats/searches`
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
(preferences, saved stations, profile, password, delete account)
Use two-file classic Livewire components. Do NOT use Volt single-file syntax. Conventions:
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`. - Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
- The fuel **prediction is embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
inline entitlement checks (see `tiers.md`).
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
the API-key gate protects the expensive station search from scraping.
## Frontend
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
and the `Logout` action.

74
.claude/rules/frontend.md Normal file
View File

@@ -0,0 +1,74 @@
# Frontend — Vue 3 SPA
The entire user-facing app (landing, station search, dashboard, settings) is a
**Vue 3 single-page application** under `resources/js/`. It is served from the
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
new features. Build Vue.
## Stack
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
- Tailwind CSS v4 (`@tailwindcss/vite`)
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
## Layout
```
resources/js/
├── app.js # Entry — createApp(App).use(router).mount('#app')
├── App.vue # Root — <RouterView/>, fetches the user on mount
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
├── views/
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
│ # PostSearchFilters, UpsellBanner, landing/*
├── composables/ # useAuth, useStations, useSavedStations
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
```
## Conventions
- **Composition API + `<script setup>` only.** No Options API.
- **All server state goes through composables** that call the configured `api`
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
component.
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
(`/login`, `/logout`) use `window.location.href`, not the router.
- Keep business logic in the API / Services. Vue components render and orchestrate.
## Search & the shareable URL
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
sharer's exact position to everyone they send the link to.
## Maps (Leaflet)
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
not a Vue plugin). Station and user markers and the Google-Maps directions links
are built from `station.lat` / `station.lng`; the user marker comes from the
browser Geolocation API.
## Prediction
The fuel prediction ships **inside** the `/api/stations` response under the
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
payload shape and the tier gate.
---
paths:
- "resources/js/**/*.vue"
- "resources/js/**/*.js"
---

View File

@@ -1,61 +0,0 @@
# Livewire Components
## Classic two-file components only
Do NOT use Volt single-file syntax for new components.
Volt files created by the Livewire starter kit (auth screens) are left untouched.
## Component locations
```
app/Livewire/
├── Dashboard/
│ ├── FuelRecommendation.php # Main fill-up/wait card
│ ├── NearbyStations.php # Map + station list
│ └── PriceHistory.php # 14-day trend chart
├── Account/
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
│ └── FuelPreferences.php # Fuel type, postcode
└── Public/
└── PriceWatchdog.php # Public-facing local price watchdog
```
## Component conventions
- Component properties that are shown in the view must be `public`
- Use `#[Computed]` attribute for derived values — not re-computed on every render
- Validate with `#[Validate]` attribute on properties, not in separate rules array
- Never put Service instantiation in the component — inject via method parameter or mount()
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
- Use `wire:loading` on all buttons that trigger server actions
## Alpine.js usage
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
## Map (Leaflet.js)
Leaflet is a plain JS drop-in, not a Livewire component.
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
```blade
<div
x-data="stationMap(@entangle('stations'))"
id="map"
style="height: 400px"
></div>
```
Map initialisation lives in `resources/js/maps/station-map.js`.
## Page routing
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
No separate view files for pages — the Livewire component IS the page.
---
paths:
- "app/Livewire/**/*.php"
- "resources/views/livewire/**/*.blade.php"
---

View File

@@ -6,7 +6,7 @@
|------------|--------|-------|------------|----------|-----------| |------------|--------|-------|------------|----------|-----------|
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ | | Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ | | Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered | | Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered | | Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
## NotificationDispatchService ## NotificationDispatchService
@@ -41,8 +41,8 @@ Subject line / message copy adapts based on `recommendation` and `confidence`.
- Same Vonage client, SMS channel - Same Vonage client, SMS channel
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it - Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
- Plus: max 8 SMS/month (enforced via alerts table count) - Plus (Smart): max 3 SMS/day (enforced via notification_log count)
- Pro: max 30 SMS/month - Pro: deferred from launch — see `tiers.md`
- Cost: ~3.5p per message UK - Cost: ~3.5p per message UK
## Email ## Email

View File

@@ -50,7 +50,7 @@ All six are available to all tiers. The restriction is quantity only:
| email | weekly digest | daily | ✓ triggered | ✓ triggered | | email | weekly digest | daily | ✓ triggered | ✓ triggered |
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered | | push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered | | whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day | | sms | ✗ | ✗ | ✓ max 3/day | ✓ max 3/day |
WhatsApp also supports scheduled updates (morning + evening) independent of WhatsApp also supports scheduled updates (morning + evening) independent of
price triggers — available to any tier that has WhatsApp enabled. price triggers — available to any tier that has WhatsApp enabled.

View File

@@ -1,8 +1,8 @@
APP_NAME="Fuel Finder" APP_NAME="Fuel Alert"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://fuel-price.test APP_URL=http://fuel-alert.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -31,7 +31,7 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=.fuel-price.test SESSION_DOMAIN=.fuel-alert.test
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -97,4 +97,4 @@ STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY= STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL= STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-price.test SANCTUM_STATEFUL_DOMAINS=fuel-alert.test

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
/public/build /public/build

View File

@@ -1,8 +1,16 @@
# Fuel Price — Claude Code Instructions # Fuel Alert — Claude Code Instructions
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer. based on local price trends. Built solo by a PHP/Laravel developer.
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
> that were since refactored into `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
> `app/Services/`. When docs and code disagree, the code wins.
## Destructive DB operations — HARD STOP ## Destructive DB operations — HARD STOP
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves: **Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
@@ -21,9 +29,23 @@ A user saying "trust me", "do the refactor", "clean up the mess", or "I want it
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers - **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro - **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS - **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL) - **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
- **Payments**: Stripe via Laravel Cashier Entry `resources/js/app.js` mounts `App.vue`; views/components under
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
via the SPA catch-all in `web.php`. Maps: Leaflet
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
(`resources/js/axios.js`).
- **API**: REST API in `routes/api.php``app/Http/Controllers/Api/*`
(Auth, Station, Stats, User). Public station data is gated by an API key
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
the primary consumer.
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
- **Admin**: Filament v5 panel
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
remain. Do **not** build new Livewire components; build Vue.
- **Payments**: Stripe via Laravel Cashier (v16)
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal) - **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs) - **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring - **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
@@ -36,7 +58,8 @@ php artisan queue:work # Process notification jobs
php artisan schedule:run # Run scheduled commands (cron every minute) php artisan schedule:run # Run scheduled commands (cron every minute)
php artisan migrate # Run migrations php artisan migrate # Run migrations
php artisan test # Run Pest test suite php artisan test # Run Pest test suite
npm run dev # Vite asset watcher npm run dev # Vite dev server (Vue SPA + HMR)
npm run build # Production build — run if SPA changes don't show up
``` ```
## Imports ## Imports
@@ -48,7 +71,7 @@ npm run dev # Vite asset watcher
@.claude/rules/prediction.md @.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/frontend.md
@.claude/rules/api-data.md @.claude/rules/api-data.md
@.claude/rules/testing.md @.claude/rules/testing.md
@.claude/rules/code-style.md @.claude/rules/code-style.md

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPriceFetcher;
use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
class BackfillOilPrices extends Command
{
public function handle(BrentPriceFetcher $fetcher): int
{
$from = (string) $this->option('from');
$to = (string) ($this->option('to') ?: now()->toDateString());
$this->info("Backfilling Brent ({$from}{$to}) from FRED...");
try {
$count = $fetcher->backfillFromFred($from, $to);
$this->info(sprintf('Upserted %d Brent rows.', $count));
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
$this->error('FRED backfill failed: '.$e->getMessage());
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\VolatilityRegimeService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:evaluate-volatility')]
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
class EvaluateVolatilityRegime extends Command
{
public function handle(VolatilityRegimeService $service): int
{
$regime = $service->evaluate();
if ($regime === null) {
$this->info('Volatility regime: OFF');
} else {
$this->info(sprintf(
'Volatility regime: ON (trigger=%s, since %s)',
$regime->trigger,
$regime->flipped_on_at->toIso8601String(),
));
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\BeisImporter;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;
#[Signature('beis:import')]
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
class ImportBeisFuelPrices extends Command
{
public function handle(BeisImporter $importer): int
{
try {
$result = $importer->import();
} catch (Throwable $e) {
$this->error('BEIS import failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Imported %d rows from %s — latest date: %s.',
$result['parsed'],
$result['csv_url'],
$result['latest_date'],
));
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
return self::SUCCESS;
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPricePredictor;
use Illuminate\Console\Command;
use Throwable;
class PredictOilPrices extends Command
{
protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
protected $description = 'Generate a Brent crude oil price direction prediction';
public function handle(BrentPricePredictor $predictor): int
{
try {
$latest = $predictor->latestPrice();
if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
$message = sprintf(
'Prediction already generated for %s at %s.',
$latest->date->toDateString(),
$latest->prediction_generated_at->toDateTimeString(),
);
if (! $this->confirm($message.' Run again anyway?', default: false)) {
$this->info('Skipped.');
return self::SUCCESS;
}
}
$this->info('Generating prediction...');
$prediction = $predictor->generatePrediction();
if ($prediction === null) {
$this->error('Could not generate a prediction — not enough price data.');
return self::FAILURE;
}
$this->info(sprintf(
'Done. [%s] direction=%s confidence=%d%% — %s',
strtoupper($prediction->source->value),
$prediction->direction->value,
$prediction->confidence,
$prediction->reasoning,
));
} catch (Throwable $e) {
$this->error("Prediction failed: {$e->getMessage()}");
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\OutcomeResolver;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:resolve-outcomes')]
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
class ResolveForecastOutcomes extends Command
{
public function handle(OutcomeResolver $resolver): int
{
$count = $resolver->resolvePending();
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\LlmOverlayService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
class RunLlmOverlay extends Command
{
public function handle(LlmOverlayService $service): int
{
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
if ($row === null) {
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
return self::SUCCESS;
}
$this->info(sprintf(
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
$row->id,
$row->direction,
$row->confidence,
$row->major_impact_event ? 'YES' : 'no',
));
return self::SUCCESS;
}
}

View File

@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
case Data; case Data;
case Forecasting;
case System; case System;
public function getLabel(): string public function getLabel(): string
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) { return match ($this) {
self::Users => 'Users', self::Users => 'Users',
self::Data => 'Data', self::Data => 'Data',
self::Forecasting => 'Forecasting',
self::System => 'System', self::System => 'System',
}; };
} }
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) { return match ($this) {
self::Users => 'heroicon-o-users', self::Users => 'heroicon-o-users',
self::Data => 'heroicon-o-circle-stack', self::Data => 'heroicon-o-circle-stack',
self::Forecasting => null,
self::System => 'heroicon-o-cog-6-tooth', self::System => 'heroicon-o-cog-6-tooth',
}; };
} }

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Backtests;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Backtests\Pages\ListBacktests;
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
use App\Models\Backtest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class BacktestResource extends Resource
{
protected static ?string $model = Backtest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Backtests';
protected static ?int $navigationSort = 3;
public static function infolist(Schema $schema): Schema
{
return BacktestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return BacktestsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListBacktests::route('/'),
'view' => ViewBacktest::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ListRecords;
class ListBacktests extends ListRecords
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBacktest extends ViewRecord
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\Backtests\Schemas;
use App\Models\Backtest;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class BacktestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Run')->columns(3)->schema([
TextEntry::make('model_version')->columnSpanFull(),
TextEntry::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%'),
TextEntry::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p'),
IconEntry::make('leak_suspected')
->label('Leak suspected')
->boolean()
->trueColor('danger'),
TextEntry::make('train_start')->date('d M Y'),
TextEntry::make('train_end')->date('d M Y'),
TextEntry::make('eval_start')->date('d M Y'),
TextEntry::make('eval_end')->date('d M Y'),
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
]),
Section::make('Calibration table')
->description('Empirical hit rate per magnitude bin from the eval window.')
->schema([
KeyValueEntry::make('calibration_table')
->hiddenLabel()
->keyLabel('Magnitude bin')
->valueLabel('Empirical hit rate')
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
->all())
->columnSpanFull(),
]),
Section::make('Feature spec')->schema([
KeyValueEntry::make('features_json')
->hiddenLabel()
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
->columnSpanFull(),
]),
Section::make('Coefficients')
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
->collapsed()
->schema([
TextEntry::make('coefficients_json')
->hiddenLabel()
->state(fn (Backtest $record): string => json_encode(
$record->coefficients_json,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) ?: '')
->columnSpanFull(),
]),
]);
}
/**
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
* into JSON strings rather than dropping them.
*
* @param array<string, mixed>|null $features
* @return array<string, string>
*/
protected static function flattenForKeyValue(?array $features): array
{
return collect($features ?? [])
->mapWithKeys(fn ($value, $key) => [
(string) $key => is_scalar($value)
? (string) $value
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
])
->all();
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\Backtests\Tables;
use App\Models\Backtest;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BacktestsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('model_version')
->searchable()
->limit(32)
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
TextColumn::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%')
->color(fn (Backtest $record) => self::accuracyColor($record))
->sortable(),
TextColumn::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p')
->sortable(),
IconColumn::make('leak_suspected')
->label('Leak?')
->boolean()
->trueColor('danger'),
TextColumn::make('eval_start')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('eval_end')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ran_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('ran_at', 'desc')
->filters([
Filter::make('leak_suspected')
->label('Suspicious accuracy (leak suspected)')
->toggle()
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
Filter::make('below_ship_gate')
->label('Below ship gate')
->toggle()
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function accuracyColor(Backtest $record): ?string
{
if ($record->directional_accuracy === null) {
return null;
}
$accuracy = (float) $record->directional_accuracy;
if ($accuracy > 75 && $record->leak_suspected) {
return 'danger';
}
if ($accuracy < 60) {
return 'danger';
}
if ($accuracy < 62) {
return 'warning';
}
if ($accuracy <= 75) {
return 'success';
}
return null;
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\NavigationGroup;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class OilPredictionResource extends Resource
{
protected static ?string $model = PricePrediction::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
protected static ?string $navigationLabel = 'Oil Predictions';
protected static ?int $navigationSort = 3;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('predicted_for')
->date('d M Y')
->sortable(),
TextColumn::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextColumn::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextColumn::make('confidence')
->suffix('%')
->sortable(),
TextColumn::make('reasoning')
->limit(60)
->placeholder('—'),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('predicted_for', 'desc')
->filters([
SelectFilter::make('source')
->options([
PredictionSource::Llm->value => 'LLM',
PredictionSource::LlmWithContext->value => 'LLM + Context',
PredictionSource::Ewma->value => 'EWMA',
]),
SelectFilter::make('direction')
->options([
TrendDirection::Rising->value => 'Rising',
TrendDirection::Falling->value => 'Falling',
TrendDirection::Flat->value => 'Flat',
]),
Filter::make('predicted_for')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(function (Builder $query, array $data) {
$query
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
}),
])
->recordActions([
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Prediction')->schema([
TextEntry::make('predicted_for')->date('d M Y'),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextEntry::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextEntry::make('confidence')->suffix('%'),
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
])->columns(3),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => ListOilPredictions::route('/'),
'view' => ViewOilPrediction::route('/{record}'),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListOilPredictions extends ListRecords
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('runPrediction')
->label('Run Prediction Now')
->icon('heroicon-o-cpu-chip')
->requiresConfirmation()
->modalHeading('Run oil price prediction?')
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
->action(function () {
$result = Artisan::call('oil:predict', ['--force' => true]);
if ($result === 0) {
Notification::make()
->title('Prediction generated successfully')
->success()
->send();
} else {
Notification::make()
->title('Prediction failed')
->body('Check API Logs for details.')
->danger()
->send();
}
}),
];
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOilPrediction extends ViewRecord
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWatchedEvent extends CreateRecord
{
protected static string $resource = WatchedEventResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWatchedEvent extends EditRecord
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWatchedEvents extends ListRecords
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WatchedEventForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextInput::make('label')
->required()
->maxLength(128)
->helperText('Short geopolitical event label, e.g. "Iran tensions AprMay 2026".'),
DateTimePicker::make('starts_at')
->label('Starts at')
->required(),
DateTimePicker::make('ends_at')
->label('Ends at')
->required()
->after('starts_at'),
Textarea::make('notes')
->maxLength(2000)
->rows(4)
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Tables;
use App\Models\WatchedEvent;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WatchedEventsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('label')
->searchable()
->sortable()
->limit(60)
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
TextColumn::make('starts_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('ends_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
TextColumn::make('notes')
->limit(50)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('starts_at', 'desc')
->filters([
Filter::make('currently_active')
->label('Currently active')
->toggle()
->query(fn (Builder $query) => $query
->where('starts_at', '<=', now())
->where('ends_at', '>=', now())),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
protected static function isActive(WatchedEvent $record): bool
{
$now = now();
return $record->starts_at !== null
&& $record->ends_at !== null
&& $record->starts_at->lessThanOrEqualTo($now)
&& $record->ends_at->greaterThanOrEqualTo($now);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\WatchedEvents;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
use App\Models\WatchedEvent;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WatchedEventResource extends Resource
{
protected static ?string $model = WatchedEvent::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Watched Events';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return WatchedEventForm::configure($schema);
}
public static function table(Table $table): Table
{
return WatchedEventsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWatchedEvents::route('/'),
'create' => CreateWatchedEvent::route('/create'),
'edit' => EditWatchedEvent::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ListRecords;
class ListWeeklyForecasts extends ListRecords
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ViewRecord;
class ViewWeeklyForecast extends ViewRecord
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
use App\Models\WeeklyForecast;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeeklyForecastInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Forecast')->columns(3)->schema([
TextEntry::make('forecast_for')->date('d M Y'),
TextEntry::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextEntry::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)),
TextEntry::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null),
IconEntry::make('flagged_duty_change')
->label('Duty change adjacent')
->boolean()
->trueColor('warning'),
TextEntry::make('generated_at')->dateTime('d M Y H:i'),
]),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded.'),
]),
Section::make('Model')
->description('Calibration table from the matching backtest determines the displayed confidence.')
->schema([
TextEntry::make('model_version')->columnSpanFull(),
]),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Tables;
use App\Models\WeeklyForecast;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WeeklyForecastsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('forecast_for')
->label('Forecast for')
->date('d M Y')
->sortable(),
TextColumn::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextColumn::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence))
->sortable(),
TextColumn::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null)
->sortable(),
IconColumn::make('flagged_duty_change')
->label('Duty change')
->boolean()
->trueColor('warning'),
TextColumn::make('model_version')
->searchable()
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('forecast_for', 'desc')
->filters([
SelectFilter::make('direction')
->multiple()
->options([
'rising' => 'Rising',
'falling' => 'Falling',
'flat' => 'Flat',
]),
Filter::make('high_confidence')
->label('High confidence')
->toggle()
->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)),
Filter::make('flagged_duty_change')
->label('Duty-change-adjacent')
->toggle()
->query(fn (Builder $query) => $query->where('flagged_duty_change', true)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
use App\Models\WeeklyForecast;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class WeeklyForecastResource extends Resource
{
protected static ?string $model = WeeklyForecast::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Weekly Forecasts';
protected static ?int $navigationSort = 2;
public static function infolist(Schema $schema): Schema
{
return WeeklyForecastInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return WeeklyForecastsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListWeeklyForecasts::route('/'),
'view' => ViewWeeklyForecast::route('/{record}'),
];
}
}

View File

@@ -3,10 +3,10 @@
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use App\Models\ApiLog; use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User; use App\Models\User;
use App\Models\WeeklyForecast;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +21,7 @@ class StatsOverviewWidget extends BaseWidget
$this->usersStat(), $this->usersStat(),
$this->searchesStat(), $this->searchesStat(),
$this->stationsStat(), $this->stationsStat(),
$this->oilPredictionStat(), $this->weeklyForecastStat(),
$this->apiErrorsStat(), $this->apiErrorsStat(),
]; ];
} }
@@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget
->color('success'); ->color('success');
} }
private function oilPredictionStat(): Stat private function weeklyForecastStat(): Stat
{ {
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first(); $forecast = WeeklyForecast::query()->latest('generated_at')->first();
if ($prediction === null) { if ($forecast === null) {
return Stat::make('Latest oil prediction', 'None') return Stat::make('Latest weekly forecast', 'None')
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color('gray'); ->color('gray');
} }
$ageHours = $prediction->generated_at->diffInHours(now()); $ageHours = $forecast->generated_at->diffInHours(now());
$color = $ageHours > 24 ? 'warning' : 'success'; $color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week
$value = $prediction->direction->label().' · '.$prediction->confidence.'%'; $directionLabel = ucfirst($forecast->direction);
$value = $directionLabel.' · '.$forecast->ridge_confidence.'%';
return Stat::make('Latest oil prediction', $value) return Stat::make('Latest weekly forecast', $value)
->description('Generated '.$prediction->generated_at->diffForHumans()) ->description('For week of '.$forecast->forecast_for->toDateString())
->url(route('filament.admin.resources.oil-predictions.index'))
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color($color); ->color($color);
} }

View File

@@ -7,7 +7,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])] #[Fillable([
'service',
'method',
'url',
'status_code',
'duration_ms',
'error',
'response_body',
'input_tokens',
'output_tokens',
'cache_read_tokens',
'cache_write_tokens',
'ratelimit_remaining',
'ratelimit_reset_at',
])]
class ApiLog extends Model class ApiLog extends Model
{ {
/** @use HasFactory<ApiLogFactory> */ /** @use HasFactory<ApiLogFactory> */
@@ -19,6 +33,7 @@ class ApiLog extends Model
{ {
return [ return [
'created_at' => 'datetime', 'created_at' => 'datetime',
'ratelimit_reset_at' => 'datetime',
]; ];
} }
} }

45
app/Models/Backtest.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Database\Factories\BacktestFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'model_version',
'features_json',
'coefficients_json',
'train_start',
'train_end',
'eval_start',
'eval_end',
'directional_accuracy',
'mae_pence',
'calibration_table',
'leak_suspected',
'ran_at',
])]
class Backtest extends Model
{
/** @use HasFactory<BacktestFactory> */
use HasFactory;
protected function casts(): array
{
return [
'features_json' => 'array',
'coefficients_json' => 'array',
'calibration_table' => 'array',
'train_start' => 'date',
'train_end' => 'date',
'eval_start' => 'date',
'eval_end' => 'date',
'directional_accuracy' => 'decimal:2',
'mae_pence' => 'decimal:2',
'leak_suspected' => 'boolean',
'ran_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'forecast_for',
'model_version',
'predicted_class',
'actual_class',
'correct',
'abs_error_pence',
'resolved_at',
])]
class ForecastOutcome extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $primaryKey = 'forecast_for';
protected $keyType = 'string';
protected function casts(): array
{
return [
'forecast_for' => 'date',
'correct' => 'boolean',
'abs_error_pence' => 'integer',
'resolved_at' => 'datetime',
];
}
}

35
app/Models/LlmOverlay.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'ran_at',
'forecast_for_week',
'direction',
'confidence',
'reasoning',
'events_json',
'agrees_with_ridge',
'major_impact_event',
'volatility_flag_on',
'search_used',
])]
class LlmOverlay extends Model
{
protected function casts(): array
{
return [
'ran_at' => 'datetime',
'forecast_for_week' => 'date',
'confidence' => 'integer',
'events_json' => 'array',
'agrees_with_ridge' => 'boolean',
'major_impact_event' => 'boolean',
'volatility_flag_on' => 'boolean',
'search_used' => 'boolean',
];
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Models;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
{
/** @use HasFactory<PricePredictionFactory> */
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'predicted_for' => 'date',
'source' => PredictionSource::class,
'direction' => TrendDirection::class,
'confidence' => 'integer',
'generated_at' => 'datetime',
];
}
/**
* Order by source quality: llm_with_context llm ewma.
* Use this whenever reading the "best" prediction for a given date.
*
* @param Builder<PricePrediction> $query
* @return Builder<PricePrediction>
*/
public function scopeBestFirst(Builder $query): Builder
{
$priority = [
PredictionSource::LlmWithContext->value,
PredictionSource::Llm->value,
PredictionSource::Ewma->value,
];
$cases = '';
foreach ($priority as $rank => $source) {
$cases .= " WHEN '$source' THEN $rank";
}
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'flipped_on_at',
'flipped_off_at',
'trigger',
'trigger_detail',
'active',
])]
class VolatilityRegime extends Model
{
protected function casts(): array
{
return [
'flipped_on_at' => 'datetime',
'flipped_off_at' => 'datetime',
'active' => 'boolean',
];
}
public static function currentlyActive(): ?self
{
return static::query()->where('active', true)->orderByDesc('flipped_on_at')->first();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Database\Factories\WatchedEventFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'label',
'starts_at',
'ends_at',
'notes',
])]
class WatchedEvent extends Model
{
/** @use HasFactory<WatchedEventFactory> */
use HasFactory;
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Database\Factories\WeeklyForecastFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'forecast_for',
'model_version',
'direction',
'magnitude_pence',
'ridge_confidence',
'flagged_duty_change',
'reasoning',
'generated_at',
])]
class WeeklyForecast extends Model
{
/** @use HasFactory<WeeklyForecastFactory> */
use HasFactory;
protected function casts(): array
{
return [
'forecast_for' => 'date',
'magnitude_pence' => 'integer',
'ridge_confidence' => 'integer',
'flagged_duty_change' => 'boolean',
'generated_at' => 'datetime',
];
}
}

View File

@@ -4,11 +4,6 @@ namespace App\Providers;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\LlmPrediction\OpenAiPredictionProvider;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -25,15 +20,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind(OilPredictionProvider::class, function ($app) { // No bindings here. The legacy LLM prediction provider binding
$logger = $app->make(ApiLogger::class); // was removed when the Phase 4 ridge model + Phase 8
// LlmOverlayService replaced the old daily oil prediction.
return match (config('services.llm.provider')) {
'openai' => new OpenAiPredictionProvider($logger),
'gemini' => new GeminiPredictionProvider($logger),
default => new AnthropicPredictionProvider($logger),
};
});
} }
/** /**

View File

@@ -3,18 +3,29 @@
namespace App\Services; namespace App\Services;
use App\Models\ApiLog; use App\Models\ApiLog;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
class ApiLogger class ApiLogger
{ {
/**
* Cap the stored response body. MEDIUMTEXT can hold ~16MB, but
* persisting more than 64KB is rarely useful for debugging and
* blows up the row size on busy services.
*/
private const int RESPONSE_BODY_CAP = 65_536;
/** /**
* Execute an HTTP request and log it to api_logs. * Execute an HTTP request and log it to api_logs.
* *
* The callable must return an Illuminate\Http\Client\Response. * The callable must return an Illuminate\Http\Client\Response.
* Exceptions are logged and re-thrown so the caller handles them. * Exceptions are logged and re-thrown so the caller handles them.
* *
* Persists the response body to `api_logs.response_body` ONLY when
* the call failed (non-2xx) or threw. Truncates to RESPONSE_BODY_CAP.
*
* @param callable(): Response $request * @param callable(): Response $request
*/ */
public function send(string $service, string $method, string $url, callable $request): Response public function send(string $service, string $method, string $url, callable $request): Response
@@ -22,19 +33,31 @@ class ApiLogger
$start = microtime(true); $start = microtime(true);
$statusCode = null; $statusCode = null;
$error = null; $error = null;
$responseBody = null;
$usage = [];
try { try {
$response = $request(); $response = $request();
$statusCode = $response->status(); $statusCode = $response->status();
$usage = $this->extractUsage($response);
if ($response->failed()) { if ($response->failed()) {
$error = Str::limit($response->body(), 1000); $body = $response->body();
$error = Str::limit($body, 1000);
$responseBody = $this->truncate($body);
} }
return $response; return $response;
} catch (Throwable $e) { } catch (Throwable $e) {
$error = $e->getMessage(); $error = $e->getMessage();
// RequestException carries the response, ConnectionException
// doesn't. Pull the body when it's available.
if ($e instanceof RequestException) {
$responseBody = $this->truncate($e->response->body());
$usage = $this->extractUsage($e->response);
}
throw $e; throw $e;
} finally { } finally {
ApiLog::create([ ApiLog::create([
@@ -44,7 +67,51 @@ class ApiLogger
'status_code' => $statusCode, 'status_code' => $statusCode,
'duration_ms' => (int) round((microtime(true) - $start) * 1000), 'duration_ms' => (int) round((microtime(true) - $start) * 1000),
'error' => $error, 'error' => $error,
'response_body' => $responseBody,
...$usage,
]); ]);
} }
} }
private function truncate(string $body): string
{
return strlen($body) > self::RESPONSE_BODY_CAP
? substr($body, 0, self::RESPONSE_BODY_CAP)
: $body;
}
/**
* Pull token-usage and rate-limit telemetry from a provider response.
*
* Today only Anthropic exposes both. Other providers return mostly
* NULLs callers don't need to know which is which.
*
* @return array<string, int|string|null>
*/
private function extractUsage(?Response $response): array
{
if ($response === null) {
return [];
}
$usage = $response->json('usage');
$tokens = is_array($usage) ? $usage : [];
$reset = $response->header('anthropic-ratelimit-input-tokens-reset');
$remaining = $response->header('anthropic-ratelimit-input-tokens-remaining');
return [
'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null),
'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null),
'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null),
'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null),
'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null),
'ratelimit_reset_at' => $reset !== '' ? $reset : null,
];
}
private function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
} }

View File

@@ -41,4 +41,24 @@ final readonly class BrentPriceFetcher
BrentPrice::upsert($rows, ['date'], ['price_usd']); BrentPrice::upsert($rows, ['date'], ['price_usd']);
} }
/**
* One-shot Brent backfill via FRED's observation_start/end. Used to
* seed `brent_prices` going back to 2018 so Phase 9's volatility
* detector and Phase 8's LLM overlay have proper context.
*
* @return int rows inserted/updated
*/
public function backfillFromFred(string $from, string $to): int
{
$rows = $this->fred->fetchRange($from, $to);
if ($rows === null) {
throw new BrentPriceFetchException("FRED backfill ({$from}{$to}) returned no data");
}
BrentPrice::upsert($rows, ['date'], ['price_usd']);
return count($rows);
}
} }

View File

@@ -1,119 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly OilPredictionProvider $provider,
) {}
/**
* Return the latest BrentPrice row, or null if none exists.
*/
public function latestPrice(): ?BrentPrice
{
return BrentPrice::orderBy('date', 'desc')->first();
}
/**
* Try LLM first; persist EWMA only as a fallback when the LLM provider
* returns null. The downstream OilSignal already prefers LLM
* (llm_with_context > llm > ewma), so writing both rows on every run is
* dead weight 95% of the time. EWMA still acts as the safety net.
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('BrentPricePredictor: not enough price data', [
'rows' => $prices->count(),
]);
return null;
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
return $llm;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $ewma;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = Ewma::compute(array_slice($chronological, -3));
$ewma7 = Ewma::compute(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}

View File

@@ -21,17 +21,50 @@ final class FredBrentPriceSource
*/ */
public function fetch(): ?array public function fetch(): ?array
{ {
try { return $this->call([
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'sort_order' => 'desc', 'sort_order' => 'desc',
'limit' => 30, 'limit' => 30,
]);
}
/**
* Backfill range (inclusive). FRED's `observation_start` /
* `observation_end` parameters expect ISO dates (YYYY-MM-DD).
* Returns null when the range is empty (e.g. all weekends/holidays).
*
* @return array{date: string, price_usd: float}[]|null
*
* @throws BrentPriceFetchException
*/
public function fetchRange(string $from, string $to): ?array
{
return $this->call([
'observation_start' => $from,
'observation_end' => $to,
'sort_order' => 'asc',
'limit' => 100000,
]);
}
/**
* @param array<string, scalar> $extraParams
* @return array{date: string, price_usd: float}[]|null
*
* @throws BrentPriceFetchException
*/
private function call(array $extraParams): ?array
{
$params = array_merge([
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'file_type' => 'json', 'file_type' => 'json',
])); ], $extraParams);
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(60)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, $params));
} catch (ConnectionException $e) { } catch (ConnectionException $e) {
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e); throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) { } catch (RequestException $e) {

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Services;
/**
* Exponentially-weighted moving average. Pure function used by
* BrentPricePredictor for the EWMA fallback prediction and by
* AnthropicPredictionProvider to enrich the basic-flow prompt.
*/
final class Ewma
{
public const float DEFAULT_ALPHA = 0.3;
/** @param float[] $prices Chronological order (oldest first). */
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = $alpha * $price + (1 - $alpha) * $ema;
}
return round($ema, 4);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\Forecasting;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Trailing-13-week hit rate for a model_version. Read from
* `forecast_outcomes`. Returns null when fewer than 4 outcomes are
* available (a single bad week would otherwise dominate the ratio).
*/
final class AccuracyHistory
{
private const int WEEKS = 13;
private const int MIN_OUTCOMES = 4;
public function trailingHitRate(string $modelVersion): ?float
{
$cutoff = Carbon::now()->subWeeks(self::WEEKS)->toDateString();
$row = DB::table('forecast_outcomes')
->where('model_version', $modelVersion)
->where('forecast_for', '>=', $cutoff)
->selectRaw('COUNT(*) as total, SUM(CASE WHEN correct THEN 1 ELSE 0 END) as correct')
->first();
$total = (int) ($row->total ?? 0);
if ($total < self::MIN_OUTCOMES) {
return null;
}
return (int) ($row->correct ?? 0) / $total;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Services\Forecasting;
use App\Models\Backtest;
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Runs a WeeklyForecastModel through a train/eval split and persists
* the result to the `backtests` table.
*
* Pipeline:
* 1. Generate the training and eval Monday lists from the date ranges.
* 2. Run LeakDetector against every Monday × every feature. Refuse to
* train if any source date is on or after a target Monday.
* 3. Train the model.
* 4. For each eval Monday: predict, look up actual ΔULSP from
* `weekly_pump_prices`, score directional accuracy + abs error.
* 5. Persist a Backtest row, return it.
*
* The `leak_suspected` flag is a *secondary* smell test (true when
* directional_accuracy > 75). Primary leak defence is step 2.
*/
final class BacktestRunner
{
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
public function __construct(
private readonly LeakDetector $leakDetector = new LeakDetector,
) {}
public function run(
WeeklyForecastModel $model,
CarbonInterface $trainStart,
CarbonInterface $trainEnd,
CarbonInterface $evalStart,
CarbonInterface $evalEnd,
): Backtest {
$trainingMondays = $this->mondaysBetween($trainStart, $trainEnd);
$evalMondays = $this->mondaysBetween($evalStart, $evalEnd);
$spec = $model->featureSpec();
$report = $this->leakDetector->validate($spec, [...$trainingMondays, ...$evalMondays]);
if ($report->hasLeaks()) {
throw new LeakDetectorException($report);
}
$model->train($trainingMondays);
$correct = 0;
$totalScored = 0;
$absErrors = [];
$bins = [];
foreach ($evalMondays as $monday) {
$actualDelta = $this->actualDeltaPence($monday);
if ($actualDelta === null) {
continue;
}
$prediction = $model->predict($monday);
$actualDirection = $this->classifyDirection($actualDelta);
$hit = $prediction->direction === $actualDirection;
$totalScored++;
$absErrors[] = abs($prediction->magnitudePence - $actualDelta);
if ($hit) {
$correct++;
}
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
$bins[$bin] ??= ['correct' => 0, 'total' => 0];
$bins[$bin]['total']++;
if ($hit) {
$bins[$bin]['correct']++;
}
}
$directionalAccuracy = $totalScored === 0
? null
: round(($correct / $totalScored) * 100, 2);
$maePence = $absErrors === []
? null
: round((array_sum($absErrors) / count($absErrors)) / 100, 2);
$calibrationTable = [];
foreach ($bins as $key => $b) {
$calibrationTable[$key] = round($b['correct'] / $b['total'], 4);
}
return Backtest::create([
'model_version' => $spec->modelVersion(),
'features_json' => $spec->toArray(),
'coefficients_json' => $model->coefficients(),
'train_start' => $trainStart->toDateString(),
'train_end' => $trainEnd->toDateString(),
'eval_start' => $evalStart->toDateString(),
'eval_end' => $evalEnd->toDateString(),
'directional_accuracy' => $directionalAccuracy,
'mae_pence' => $maePence,
'calibration_table' => $calibrationTable,
'leak_suspected' => $directionalAccuracy !== null && $directionalAccuracy > 75.0,
'ran_at' => now(),
]);
}
/** @return array<int, CarbonInterface> */
private function mondaysBetween(CarbonInterface $start, CarbonInterface $end): array
{
$mondays = [];
$cursor = $start->copy()->startOfDay();
$boundary = $end->copy()->startOfDay();
while ($cursor->lessThanOrEqualTo($boundary)) {
if ($cursor->dayOfWeek === CarbonInterface::MONDAY) {
$mondays[] = $cursor->copy();
}
$cursor = $cursor->addDay();
}
return $mondays;
}
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
{
$current = DB::table('weekly_pump_prices')
->where('date', $targetMonday->toDateString())
->value('ulsp_pence');
$previous = DB::table('weekly_pump_prices')
->where('date', $targetMonday->copy()->subDays(7)->toDateString())
->value('ulsp_pence');
if ($current === null || $previous === null) {
return null;
}
return (float) ($current - $previous);
}
private function classifyDirection(float $deltaPence): string
{
return match (true) {
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
default => 'flat',
};
}
private function bucketForMagnitude(float $magnitudePence): string
{
$abs = abs($magnitudePence);
return match (true) {
$abs < 50.0 => '0.0-0.5p',
$abs < 100.0 => '0.5-1.0p',
default => '1.0p+',
};
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Services\Forecasting;
use DateTime;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Pulls the latest "Weekly road fuel prices (CSV) 2018 to 2026"
* attachment from gov.uk's content API and upserts into
* `weekly_pump_prices`.
*
* Idempotent: re-running on a day with no new publication is a no-op
* (rows match by primary key `date`, content is unchanged).
*
* The forecast cache is busted at the end so the next API hit retrains
* the ridge model on the fresh row.
*/
final class BeisImporter
{
private const string API_URL = 'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices';
private const string ATTACHMENT_TITLE = 'Weekly road fuel prices (CSV) 2018 to 2026';
/**
* @return array{
* csv_url: string,
* parsed: int,
* upserted: int,
* latest_date: string,
* }
*/
public function import(): array
{
$url = $this->resolveCsvUrl();
$csv = $this->downloadCsv($url);
$rows = $this->parse($csv);
if ($rows === []) {
throw new RuntimeException('BEIS CSV parsed empty — check delimiter / encoding');
}
DB::table('weekly_pump_prices')->upsert(
$rows,
['date'],
['ulsp_pence', 'ulsd_pence', 'ulsp_duty_pence', 'ulsd_duty_pence', 'ulsp_vat_pct', 'ulsd_vat_pct'],
);
Cache::flush();
$latest = (string) collect($rows)->pluck('date')->sortDesc()->first();
return [
'csv_url' => $url,
'parsed' => count($rows),
'upserted' => count($rows),
'latest_date' => $latest,
];
}
private function resolveCsvUrl(): string
{
$response = Http::timeout(15)->acceptJson()->get(self::API_URL);
$response->throw();
$attachments = $response->json('details.attachments', []);
foreach ($attachments as $a) {
if (($a['title'] ?? null) === self::ATTACHMENT_TITLE) {
$url = $a['url'] ?? null;
if (! is_string($url) || $url === '') {
throw new RuntimeException('BEIS attachment had empty URL');
}
return $url;
}
}
throw new RuntimeException(sprintf(
'gov.uk content API did not return an attachment titled %s',
self::ATTACHMENT_TITLE,
));
}
private function downloadCsv(string $url): string
{
$response = Http::timeout(60)->get($url);
$response->throw();
return $response->body();
}
/**
* @return array<int, array<string, int|string>>
*/
private function parse(string $csv): array
{
$rows = [];
$lines = preg_split('/\r\n|\r|\n/', $csv);
if ($lines === false || count($lines) < 2) {
return [];
}
// Skip header.
array_shift($lines);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$cols = str_getcsv($line, escape: '\\');
if (count($cols) < 7) {
continue;
}
$date = DateTime::createFromFormat('d/m/Y', trim($cols[0]));
if ($date === false) {
continue;
}
$rows[] = [
'date' => $date->format('Y-m-d'),
'ulsp_pence' => (int) round(((float) $cols[1]) * 100),
'ulsd_pence' => (int) round(((float) $cols[2]) * 100),
'ulsp_duty_pence' => (int) round(((float) $cols[3]) * 100),
'ulsd_duty_pence' => (int) round(((float) $cols[4]) * 100),
'ulsp_vat_pct' => (int) $cols[5],
'ulsd_vat_pct' => (int) $cols[6],
];
}
return $rows;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Forecasting\Contracts;
use Carbon\CarbonInterface;
/**
* A single feature in a weekly forecast model.
*
* Implementations must be deterministic for a given target Monday and
* must declare every source date they read so the LeakDetector can
* verify no source date is on or after the target Monday.
*/
interface ForecastFeature
{
public function name(): string;
/**
* Feature value at $targetMonday, or null when an upstream data
* row is missing. Caller is expected to drop the entire feature
* vector when any single feature is null.
*/
public function valueFor(CarbonInterface $targetMonday): ?float;
/**
* Every date this feature reads from any data source for a given
* target Monday. The LeakDetector requires every returned date to
* be strictly before $targetMonday.
*
* @return array<int, CarbonInterface>
*/
public function sourceDates(CarbonInterface $targetMonday): array;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Forecasting\Contracts;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\WeeklyPrediction;
use Carbon\CarbonInterface;
/**
* Contract every weekly forecaster must satisfy. The harness consumes
* this interface naive baselines, ridge regression, and any future
* model all implement it.
*/
interface WeeklyForecastModel
{
public function featureSpec(): FeatureSpec;
/**
* Train on the supplied weeks. Implementations may store coefficients
* internally for the subsequent predict() calls.
*
* @param array<int, CarbonInterface> $trainingMondays
*/
public function train(array $trainingMondays): void;
/**
* Predict ΔULSP for the week starting $targetMonday. Returned value
* is in pence × 100 (integer-ish, but typed float for fractional
* predictions).
*/
public function predict(CarbonInterface $targetMonday): WeeklyPrediction;
/**
* Coefficients in a JSON-serialisable form, or null for non-parametric
* models like the naive baseline.
*
* @return array<string, mixed>|null
*/
public function coefficients(): ?array;
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services\Forecasting;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Flags forecast weeks that fall within ±4 weeks of a known UK fuel
* duty change. Per the spec calibration override (n=1), the displayed
* confidence on flagged weeks is halved and the reasoning text says so.
*/
final class DutyChangeDetector
{
public const int FLAG_RADIUS_WEEKS = 4;
/**
* Returns true if the target Monday is within ±4 weeks of any
* change in `weekly_pump_prices.ulsp_duty_pence`.
*/
public function isAdjacent(CarbonInterface $targetMonday): bool
{
$start = $targetMonday->copy()->subWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
$end = $targetMonday->copy()->addWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
$rows = DB::table('weekly_pump_prices')
->whereBetween('date', [$start, $end])
->orderBy('date')
->get(['date', 'ulsp_duty_pence']);
if ($rows->count() < 2) {
return false;
}
$previous = null;
foreach ($rows as $r) {
if ($previous !== null && (int) $r->ulsp_duty_pence !== $previous) {
return true;
}
$previous = (int) $r->ulsp_duty_pence;
}
return false;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Services\Forecasting;
use App\Services\Forecasting\Contracts\ForecastFeature;
use InvalidArgumentException;
/**
* Immutable list of features a model uses, plus a deterministic hash
* for audit linking on backtests.model_version.
*
* Two FeatureSpec instances with the same feature names + same model
* label produce the same hash, so retraining the same model
* configuration overwrites the same `backtests` row (via UNIQUE on
* model_version).
*/
final readonly class FeatureSpec
{
/** @param array<int, ForecastFeature> $features */
public function __construct(
public string $modelLabel,
public array $features,
) {
foreach ($features as $f) {
if (! $f instanceof ForecastFeature) {
throw new InvalidArgumentException('Every spec entry must implement ForecastFeature');
}
}
}
/** @return array<int, string> */
public function names(): array
{
return array_map(fn (ForecastFeature $f): string => $f->name(), $this->features);
}
public function modelVersion(): string
{
$names = $this->names();
sort($names);
$hash = substr(sha1(json_encode($names, JSON_THROW_ON_ERROR)), 0, 12);
return $this->modelLabel.'-'.$hash;
}
/** @return array{model_label: string, features: array<int, string>} */
public function toArray(): array
{
return [
'model_label' => $this->modelLabel,
'features' => $this->names(),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Forecasting\Features;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\CarbonInterface;
/**
* ΔULSD at lag L. Cross-fuel signal diesel often leads/lags petrol
* during oil shocks. Same lag semantics as DeltaUlspLag.
*/
final class DeltaUlsdLag implements ForecastFeature
{
public function __construct(
private readonly WeeklyPumpPriceLoader $loader,
public readonly int $lag,
) {}
public function name(): string
{
return 'delta_ulsd_lag_'.$this->lag;
}
public function valueFor(CarbonInterface $targetMonday): ?float
{
[$newer, $older] = $this->dates($targetMonday);
$a = $this->loader->ulsdPence($newer->toDateString());
$b = $this->loader->ulsdPence($older->toDateString());
if ($a === null || $b === null) {
return null;
}
return (float) ($a - $b);
}
public function sourceDates(CarbonInterface $targetMonday): array
{
return $this->dates($targetMonday);
}
/** @return array{0: CarbonInterface, 1: CarbonInterface} */
private function dates(CarbonInterface $targetMonday): array
{
return [
$targetMonday->copy()->subDays(7 * ($this->lag + 1)),
$targetMonday->copy()->subDays(7 * ($this->lag + 2)),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Forecasting\Features;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\CarbonInterface;
/**
* ΔULSP at lag L: the change in petrol price that ended L weeks before
* the most recent observation, in pence × 100.
*
* lag=0 ULSP[t-7d] ULSP[t-14d] (1-week momentum)
* lag=1 ULSP[t-14d] ULSP[t-21d] (2-week momentum)
* lag=3 ULSP[t-28d] ULSP[t-35d] (4-week momentum)
*
* Source dates are always strictly before the target Monday the
* earliest is target 7×(lag+1), the older is target 7×(lag+2).
*/
final class DeltaUlspLag implements ForecastFeature
{
public function __construct(
private readonly WeeklyPumpPriceLoader $loader,
public readonly int $lag,
) {}
public function name(): string
{
return 'delta_ulsp_lag_'.$this->lag;
}
public function valueFor(CarbonInterface $targetMonday): ?float
{
[$newer, $older] = $this->dates($targetMonday);
$a = $this->loader->ulspPence($newer->toDateString());
$b = $this->loader->ulspPence($older->toDateString());
if ($a === null || $b === null) {
return null;
}
return (float) ($a - $b);
}
public function sourceDates(CarbonInterface $targetMonday): array
{
return $this->dates($targetMonday);
}
/** @return array{0: CarbonInterface, 1: CarbonInterface} */
private function dates(CarbonInterface $targetMonday): array
{
return [
$targetMonday->copy()->subDays(7 * ($this->lag + 1)),
$targetMonday->copy()->subDays(7 * ($this->lag + 2)),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Services\Forecasting\Features;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\UkBankHolidays;
use Carbon\CarbonInterface;
/**
* 1.0 if any UK bank holiday falls in the 7-day window starting at the
* target Monday; 0.0 otherwise.
*
* Captures pre-holiday demand spikes (Easter, summer, Christmas
* weekend). Pure calendar no DB read, sourceDates is empty.
*/
final class IsPreBankHoliday implements ForecastFeature
{
public function name(): string
{
return 'is_pre_bank_holiday';
}
public function valueFor(CarbonInterface $targetMonday): ?float
{
return UkBankHolidays::holidayWithin($targetMonday, 7) ? 1.0 : 0.0;
}
public function sourceDates(CarbonInterface $targetMonday): array
{
return [];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Forecasting\Features;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\CarbonInterface;
/**
* Mean-reversion term: gap between the most recent observable ULSP
* (target 7d) and its 8-week trailing mean (target 7d through
* target 56d, inclusive).
*
* Empirically this is the single most useful 1-week-ahead feature for
* UK pump prices pump retailers tend to revert to their recent
* trailing mean, especially after sudden moves.
*/
final class UlspMinusMa8 implements ForecastFeature
{
private const int WINDOW_WEEKS = 8;
public function __construct(
private readonly WeeklyPumpPriceLoader $loader,
) {}
public function name(): string
{
return 'ulsp_minus_ma8';
}
public function valueFor(CarbonInterface $targetMonday): ?float
{
$values = [];
foreach ($this->sourceDates($targetMonday) as $d) {
$v = $this->loader->ulspPence($d->toDateString());
if ($v === null) {
return null;
}
$values[] = (float) $v;
}
$latest = $values[0];
$mean = array_sum($values) / count($values);
return $latest - $mean;
}
public function sourceDates(CarbonInterface $targetMonday): array
{
$dates = [];
for ($w = 1; $w <= self::WINDOW_WEEKS; $w++) {
$dates[] = $targetMonday->copy()->subDays(7 * $w);
}
return $dates;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Forecasting\Features;
use App\Services\Forecasting\Contracts\ForecastFeature;
use Carbon\CarbonInterface;
use InvalidArgumentException;
/**
* Cyclic week-of-year encoding. Two instances expected, one for sin and
* one for cos. Together they let the linear model fit a smooth annual
* seasonal cycle without a 52-way one-hot expansion.
*
* This is a pure calendar feature no DB read. sourceDates is empty,
* so the LeakDetector has nothing to validate against.
*/
final class WeekOfYearTrig implements ForecastFeature
{
public function __construct(public readonly string $component)
{
if (! in_array($component, ['sin', 'cos'], true)) {
throw new InvalidArgumentException('component must be "sin" or "cos"');
}
}
public function name(): string
{
return 'week_of_year_'.$this->component;
}
public function valueFor(CarbonInterface $targetMonday): ?float
{
$week = (int) $targetMonday->format('W'); // ISO week number 1..53
$angle = 2.0 * M_PI * $week / 52.0;
return $this->component === 'sin' ? sin($angle) : cos($angle);
}
public function sourceDates(CarbonInterface $targetMonday): array
{
return [];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Services\Forecasting;
use Carbon\CarbonInterface;
/**
* Structural time-leak detector.
*
* For every (training week, feature) pair, verifies that every source
* date the feature reads is strictly before the target Monday. A
* source date on or after the target Monday is leakage and the
* backtest harness must refuse to run.
*
* This is the *primary* leak defence. The accuracy>75% smell test on
* the resulting backtest is a secondary check.
*/
final class LeakDetector
{
/** @param array<int, CarbonInterface> $trainingMondays */
public function validate(FeatureSpec $spec, array $trainingMondays): LeakReport
{
$leaks = [];
foreach ($trainingMondays as $target) {
foreach ($spec->features as $feature) {
foreach ($feature->sourceDates($target) as $source) {
if ($source->greaterThanOrEqualTo($target)) {
$leaks[] = [
'feature' => $feature->name(),
'target_monday' => $target->toDateString(),
'source_date' => $source->toDateString(),
];
}
}
}
}
return new LeakReport($leaks);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Services\Forecasting;
use RuntimeException;
final class LeakDetectorException extends RuntimeException
{
public function __construct(public readonly LeakReport $report)
{
$count = count($report->leaks);
$first = $report->leaks[0] ?? null;
$sample = $first === null
? ''
: sprintf(' First: feature "%s" reads %s for target %s.', $first['feature'], $first['source_date'], $first['target_monday']);
parent::__construct(sprintf('Structural time leak detected in %d feature value(s).%s', $count, $sample));
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Services\Forecasting;
/**
* Result of a LeakDetector::validate() run.
*
* Each entry in $leaks is shape:
* { feature: string, target_monday: 'Y-m-d', source_date: 'Y-m-d' }
*/
final readonly class LeakReport
{
/** @param array<int, array{feature: string, target_monday: string, source_date: string}> $leaks */
public function __construct(public array $leaks) {}
public function hasLeaks(): bool
{
return $this->leaks !== [];
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Services\Forecasting;
use InvalidArgumentException;
use RuntimeException;
/**
* Pure-PHP linear algebra used by RidgeRegressionModel.
*
* Matrices are array<int, array<int, float>>. Vectors are array<int, float>.
* Sized for the v1 ridge model (435 × 8); GaussJordan with partial
* pivoting is plenty for inverting the 8 × 8 normal-equation matrix.
*/
final class LinearAlgebra
{
/**
* Transpose. m is rows × cols result is cols × rows.
*
* @param array<int, array<int, float>> $m
* @return array<int, array<int, float>>
*/
public static function transpose(array $m): array
{
$rows = count($m);
if ($rows === 0) {
return [];
}
$cols = count($m[0]);
$out = array_fill(0, $cols, array_fill(0, $rows, 0.0));
for ($i = 0; $i < $rows; $i++) {
for ($j = 0; $j < $cols; $j++) {
$out[$j][$i] = $m[$i][$j];
}
}
return $out;
}
/**
* Matrix multiply. a (r×k) * b (k×c) r×c.
*
* @param array<int, array<int, float>> $a
* @param array<int, array<int, float>> $b
* @return array<int, array<int, float>>
*/
public static function multiply(array $a, array $b): array
{
$r = count($a);
$k = count($a[0] ?? []);
$c = count($b[0] ?? []);
if (count($b) !== $k) {
throw new InvalidArgumentException('Matrix multiply dimension mismatch');
}
$out = array_fill(0, $r, array_fill(0, $c, 0.0));
for ($i = 0; $i < $r; $i++) {
for ($j = 0; $j < $c; $j++) {
$sum = 0.0;
for ($p = 0; $p < $k; $p++) {
$sum += $a[$i][$p] * $b[$p][$j];
}
$out[$i][$j] = $sum;
}
}
return $out;
}
/**
* Matrix × vector. a (r×k) * v (k) r-vector.
*
* @param array<int, array<int, float>> $a
* @param array<int, float> $v
* @return array<int, float>
*/
public static function multiplyVector(array $a, array $v): array
{
$r = count($a);
$k = count($v);
if (count($a[0] ?? []) !== $k) {
throw new InvalidArgumentException('Matrix × vector dimension mismatch');
}
$out = array_fill(0, $r, 0.0);
for ($i = 0; $i < $r; $i++) {
$sum = 0.0;
for ($p = 0; $p < $k; $p++) {
$sum += $a[$i][$p] * $v[$p];
}
$out[$i] = $sum;
}
return $out;
}
/**
* Identity matrix of size n.
*
* @return array<int, array<int, float>>
*/
public static function identity(int $n): array
{
$out = array_fill(0, $n, array_fill(0, $n, 0.0));
for ($i = 0; $i < $n; $i++) {
$out[$i][$i] = 1.0;
}
return $out;
}
/**
* Solve A x = b using GaussJordan elimination with partial pivoting.
* A is square n×n. Returns x as an n-vector.
*
* @param array<int, array<int, float>> $A
* @param array<int, float> $b
* @return array<int, float>
*/
public static function solve(array $A, array $b): array
{
$n = count($A);
if (count($b) !== $n) {
throw new InvalidArgumentException('solve: RHS dimension mismatch');
}
// Build augmented matrix.
$aug = [];
for ($i = 0; $i < $n; $i++) {
$aug[$i] = array_merge($A[$i], [$b[$i]]);
}
for ($col = 0; $col < $n; $col++) {
// Partial pivot: find row with largest |value| in this column.
$pivot = $col;
$best = abs($aug[$col][$col]);
for ($r = $col + 1; $r < $n; $r++) {
$v = abs($aug[$r][$col]);
if ($v > $best) {
$best = $v;
$pivot = $r;
}
}
if ($best < 1e-12) {
throw new RuntimeException('solve: matrix is singular or near-singular');
}
if ($pivot !== $col) {
[$aug[$col], $aug[$pivot]] = [$aug[$pivot], $aug[$col]];
}
// Normalise pivot row.
$div = $aug[$col][$col];
for ($j = 0; $j <= $n; $j++) {
$aug[$col][$j] /= $div;
}
// Eliminate this column from every other row.
for ($r = 0; $r < $n; $r++) {
if ($r === $col) {
continue;
}
$factor = $aug[$r][$col];
if ($factor === 0.0) {
continue;
}
for ($j = 0; $j <= $n; $j++) {
$aug[$r][$j] -= $factor * $aug[$col][$j];
}
}
}
$x = array_fill(0, $n, 0.0);
for ($i = 0; $i < $n; $i++) {
$x[$i] = $aug[$i][$n];
}
return $x;
}
/**
* Ridge solve: β = (XᵀX + λI) ⁻¹ Xᵀy.
*
* λ is applied to all coefficients. Caller should standardise X and
* centre y before calling, then add intercept back externally the
* intercept must NOT be regularised.
*
* @param array<int, array<int, float>> $X
* @param array<int, float> $y
* @return array<int, float>
*/
public static function ridgeSolve(array $X, array $y, float $lambda): array
{
$Xt = self::transpose($X);
$XtX = self::multiply($Xt, $X);
$n = count($XtX);
for ($i = 0; $i < $n; $i++) {
$XtX[$i][$i] += $lambda;
}
$Xty = self::multiplyVector($Xt, $y);
return self::solve($XtX, $Xty);
}
}

View File

@@ -0,0 +1,658 @@
<?php
namespace App\Services\Forecasting;
use App\Models\BrentPrice;
use App\Models\LlmOverlay;
use App\Models\VolatilityRegime;
use App\Services\ApiLogger;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Layer 4 daily news-aware overlay on the calibrated ridge forecast.
*
* Runs as two independent Anthropic API calls:
* Phase 1 web_search tool only; we capture the URLs/titles from
* the returned web_search_tool_result blocks.
* Phase 2 fresh conversation containing those URLs+titles as plain
* text plus a forced submit_overlay tool call.
*
* Phase 1's transcript is never sent back to Phase 2. Anthropic's
* web_search auto-caches the encrypted page text (~55k tokens per
* search) and requires it intact when web_search_tool_result blocks
* are resent. Threading it through to Phase 2 either blows the Tier-1
* 50k ITPM bucket or 400s if we try to strip it. Two clean calls keep
* Phase 2 around 3k input tokens.
*
* Citations are harvested directly from Phase 1's web_search_tool_result
* blocks Haiku is unreliable about populating `events_cited` itself.
*
* Read-only with respect to the volatility flag Layer 4 writes its
* `llm_overlays` row; Layer 5's hourly cron picks it up and decides
* whether to flip the regime.
*/
final class LlmOverlayService
{
private const string URL = 'https://api.anthropic.com/v1/messages';
private const int CONFIDENCE_CAP = 75;
private const int COOLDOWN_HOURS = 4;
private const int MAX_SEARCH_TURNS = 2;
/**
* Approximate input-token cost of Phase 2 (system + tool schema +
* forecast context + harvested URL list). If Phase 1 leaves
* remaining ITPM below this, wait for the bucket to refill.
*/
private const int SUBMIT_TOKEN_BUDGET = 4_000;
public function __construct(
private readonly ApiLogger $apiLogger,
private readonly WeeklyForecastService $weeklyForecast,
) {}
/**
* Run an overlay generation. $eventDriven=true respects the 4-hour
* cooldown; the daily 07:00 cron passes false to always run.
*/
public function run(bool $eventDriven = false): ?LlmOverlay
{
if ($this->apiKey() === null) {
Log::info('LlmOverlayService: no ANTHROPIC_API_KEY, skipping');
return null;
}
if ($eventDriven && $this->onCooldown()) {
return null;
}
$forecast = $this->weeklyForecast->currentForecast();
$context = $this->buildContext($forecast);
$callResult = $this->callAnthropic($context);
if ($callResult === null) {
return null;
}
$rawResult = $callResult['raw'];
$harvested = $callResult['harvested'];
$mergedEvents = $this->mergeEvents($rawResult['events_cited'] ?? [], $harvested);
$verifiedEvents = $this->verifyCitedUrls($mergedEvents);
if ($verifiedEvents === []) {
Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [
'model_events' => $rawResult['events_cited'] ?? null,
'harvested_urls' => array_column($harvested, 'url'),
'direction' => $rawResult['direction'] ?? null,
'confidence' => $rawResult['confidence'] ?? null,
'reasoning_short' => $rawResult['reasoning_short'] ?? null,
]);
return null;
}
$confidence = max(0, min(self::CONFIDENCE_CAP, (int) ($rawResult['confidence'] ?? 0)));
$direction = $rawResult['direction'] ?? 'flat';
$agreesWithRidge = $direction === $this->ridgeDirection($forecast['predicted_direction']);
return LlmOverlay::query()->create([
'ran_at' => now(),
'forecast_for_week' => $this->upcomingMondayDateString(),
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => (string) ($rawResult['reasoning_short'] ?? ''),
'events_json' => $verifiedEvents,
'agrees_with_ridge' => $agreesWithRidge,
'major_impact_event' => (bool) ($rawResult['major_impact_event'] ?? false),
'volatility_flag_on' => VolatilityRegime::currentlyActive() !== null,
'search_used' => true,
]);
}
private function onCooldown(): bool
{
$latest = LlmOverlay::query()->orderByDesc('ran_at')->first();
return $latest !== null
&& $latest->ran_at->greaterThanOrEqualTo(now()->subHours(self::COOLDOWN_HOURS));
}
/** @return array<string, mixed> */
private function buildContext(array $forecast): array
{
$ulspWeekly = DB::table('weekly_pump_prices')
->orderByDesc('date')
->limit(8)
->get(['date', 'ulsp_pence'])
->reverse()
->map(fn ($r): array => ['date' => (string) $r->date, 'ulsp_pence' => round((int) $r->ulsp_pence / 100, 1)])
->values()
->all();
$brentRecent = BrentPrice::query()
->orderByDesc('date')
->limit(14)
->get(['date', 'price_usd'])
->reverse()
->map(fn (BrentPrice $r): array => ['date' => (string) $r->date->toDateString(), 'price_usd' => (float) $r->price_usd])
->values()
->all();
return [
'ulsp_recent_8_weeks' => $ulspWeekly,
'brent_recent_14_days' => $brentRecent,
'ridge_model_says' => [
'direction' => $forecast['predicted_direction'] ?? 'stable',
'confidence' => $forecast['confidence_score'] ?? 0,
'magnitude_pence' => $forecast['predicted_change_pence'] ?? 0,
],
];
}
/**
* Two independent API calls:
*
* Phase 1 runs the web_search tool, captures the assistant's
* returned `web_search_tool_result` blocks, then
* discards the transcript.
*
* Phase 2 issues a brand-new conversation with the harvested
* URLs/titles flattened into a plain-text user message
* and forces a `submit_overlay` tool call.
*
* Why not one stitched conversation: Anthropic auto-caches web_search
* results into ITPM (≈55k tokens for a 1-search call) and requires
* `encrypted_content` intact when those blocks are sent back.
* Resending the Phase 1 transcript to Phase 2 either rate-limits us
* (29k+ tokens twice exceeds the Tier-1 50k ITPM bucket) or 400s
* if we strip the encrypted blob. A fresh Phase 2 sends ~3k tokens
* total small enough to fit in the recovered bucket after a
* short adaptive sleep.
*
* @return array{raw: array<string, mixed>, harvested: array<int, array{url: string, title: string}>}|null
*/
private function callAnthropic(array $context): ?array
{
try {
$phase1 = $this->runWebSearch($context);
if ($phase1 === null) {
return null;
}
$this->waitForRateLimitIfNeeded($phase1['response']);
$rawResult = $this->runSubmit($context, $phase1['harvested']);
if ($rawResult === null) {
return null;
}
return ['raw' => $rawResult, 'harvested' => $phase1['harvested']];
} catch (Throwable $e) {
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Phase 1: ask the model to search for news and capture the
* web_search_tool_result blocks. Returns the harvested citations
* and the final response (whose rate-limit headers tell us when
* the ITPM bucket will be replenished for Phase 2).
*
* @return array{harvested: array<int, array{url: string, title: string}>, response: Response}|null
*/
private function runWebSearch(array $context): ?array
{
$messages = [['role' => 'user', 'content' => $this->searchUserMessage($context)]];
$response = null;
for ($i = 0; $i < self::MAX_SEARCH_TURNS; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
->withHeaders($this->headers())
->post(self::URL, [
'model' => $this->model(),
'max_tokens' => 1024,
'system' => $this->searchSystem(),
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('LlmOverlayService: search request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
return null;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
}
if ($response === null) {
return null;
}
return [
'harvested' => $this->harvestSearchResults($messages),
'response' => $response,
];
}
/**
* Phase 2: fresh API call no Phase 1 transcript with the
* harvested citations as plain text and a forced submit_overlay
* tool call.
*
* @param array<int, array{url: string, title: string}> $harvested
* @return array<string, mixed>|null
*/
private function runSubmit(array $context, array $harvested): ?array
{
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
->withHeaders($this->headers())
->post(self::URL, [
'model' => $this->model(),
'max_tokens' => 512,
'system' => $this->submitSystem(),
'tools' => [$this->submitOverlayTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
'messages' => [['role' => 'user', 'content' => $this->submitUserMessage($context, $harvested)]],
]));
if (! $response->successful()) {
Log::error('LlmOverlayService: submit request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
return null;
}
$rawResult = $this->extractToolInput($response->json('content') ?? []);
if ($rawResult === null) {
Log::warning('LlmOverlayService: submit response missing tool_use block');
return null;
}
return $rawResult;
}
/**
* Anthropic's web_search burns ≈55k input tokens (mostly auto-cached
* search results) on Phase 1. At Tier 1's 50k ITPM the bucket can
* be at zero immediately afterwards. Read the rate-limit headers
* and sleep until the bucket has refilled enough for Phase 2.
* Capped at 65s so the daily cron never hangs longer than a minute.
*/
private function waitForRateLimitIfNeeded(Response $response): void
{
$remaining = (int) $response->header('anthropic-ratelimit-input-tokens-remaining');
if ($response->header('anthropic-ratelimit-input-tokens-remaining') === ''
|| $remaining >= self::SUBMIT_TOKEN_BUDGET) {
return;
}
$resetAt = $response->header('anthropic-ratelimit-input-tokens-reset');
$bucketSize = (int) $response->header('anthropic-ratelimit-input-tokens-limit');
if ($resetAt === '' || $bucketSize <= 0) {
return;
}
try {
$secondsUntilFullReset = max(0, CarbonImmutable::parse($resetAt)->getTimestamp() - now()->getTimestamp());
} catch (Throwable) {
return;
}
// Anthropic's bucket refills linearly. We don't need to wait for
// the full reset — only enough for SUBMIT_TOKEN_BUDGET tokens to
// become available. Sleep proportionally + a small safety margin,
// hard-capped at 65s.
$tokensNeeded = self::SUBMIT_TOKEN_BUDGET - $remaining;
$proportional = (int) ceil(($tokensNeeded / $bucketSize) * $secondsUntilFullReset);
$waitSeconds = max(1, min(65, $proportional + 2));
Log::info('LlmOverlayService: waiting for ITPM bucket refill before submit', [
'remaining' => $remaining,
'wait_seconds' => $waitSeconds,
'full_reset_in' => $secondsUntilFullReset,
]);
sleep($waitSeconds);
}
/**
* Walk every assistant turn and extract `{url, title}` from each
* `web_search_tool_result` block. Anthropic's web_search returns
* these blocks directly they are the authoritative citation
* source, not anything the model transcribes back to us.
*
* @param array<int, array<string, mixed>> $messages
* @return array<int, array{url: string, title: string}>
*/
private function harvestSearchResults(array $messages): array
{
$byUrl = [];
foreach ($messages as $message) {
if (($message['role'] ?? null) !== 'assistant') {
continue;
}
$content = $message['content'] ?? [];
if (! is_array($content)) {
continue;
}
foreach ($content as $block) {
if (! is_array($block) || ($block['type'] ?? null) !== 'web_search_tool_result') {
continue;
}
$results = $block['content'] ?? [];
if (! is_array($results)) {
continue;
}
foreach ($results as $result) {
if (! is_array($result) || ($result['type'] ?? null) !== 'web_search_result') {
continue;
}
$url = (string) ($result['url'] ?? '');
if ($url === '' || isset($byUrl[$url])) {
continue;
}
$byUrl[$url] = ['url' => $url, 'title' => (string) ($result['title'] ?? '')];
}
}
}
return array_values($byUrl);
}
/**
* Merge model-provided events_cited with citations harvested from
* `web_search_tool_result`. Model entries (which include `impact`
* tagging) take precedence on URL collision; harvested-only entries
* default to `impact: 'neutral'`.
*
* @param array<int, mixed> $modelEvents
* @param array<int, array{url: string, title: string}> $harvested
* @return array<int, array<string, mixed>>
*/
private function mergeEvents(array $modelEvents, array $harvested): array
{
$byUrl = [];
foreach ($modelEvents as $event) {
if (! is_array($event)) {
continue;
}
$url = (string) ($event['url'] ?? '');
if ($url === '') {
continue;
}
$byUrl[$url] = [
'headline' => (string) ($event['headline'] ?? ''),
'source' => (string) ($event['source'] ?? ''),
'url' => $url,
'impact' => in_array($event['impact'] ?? null, ['rising', 'falling', 'neutral'], true)
? $event['impact']
: 'neutral',
];
}
foreach ($harvested as $result) {
$url = $result['url'];
if (isset($byUrl[$url])) {
continue;
}
$byUrl[$url] = [
'headline' => $result['title'],
'source' => $this->domainOf($url),
'url' => $url,
'impact' => 'neutral',
];
}
return array_values($byUrl);
}
private function domainOf(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
return is_string($host) ? preg_replace('/^www\./', '', $host) : '';
}
private function verificationUserAgent(): string
{
$appUrl = rtrim((string) config('app.url'), '/');
return "Mozilla/5.0 (compatible; FuelPriceBot/1.0; +{$appUrl}/bot)";
}
/**
* Verify each cited URL is reachable. Major news sites (Reuters, FT,
* Bloomberg, BBC...) often reject HEAD with 403 / 405 even though
* GET works fine. So: try HEAD first, then fall back to a 1-byte
* GET (Range header) when HEAD fails. Both must include a
* browser-shaped User-Agent or Cloudflare etc. block us as a bot.
*
* Every URL verified or rejected is logged at INFO/WARNING so
* operators can debug rejections from `storage/logs/laravel.log`
* without needing to capture the Anthropic response body.
*
* @param array<int, array<string, mixed>> $events
* @return array<int, array<string, mixed>>
*/
private function verifyCitedUrls(array $events): array
{
$verified = [];
foreach ($events as $event) {
$url = (string) ($event['url'] ?? '');
if ($url === '') {
Log::warning('LlmOverlayService: dropping cited event with empty URL', [
'headline' => $event['headline'] ?? null,
'source' => $event['source'] ?? null,
]);
continue;
}
[$reachable, $diagnosis] = $this->urlReachable($url);
if ($reachable) {
Log::info('LlmOverlayService: URL verified', [
'url' => $url,
'via' => $diagnosis,
]);
$verified[] = $event;
} else {
Log::warning('LlmOverlayService: URL rejected', [
'url' => $url,
'reason' => $diagnosis,
'headline' => $event['headline'] ?? null,
'source' => $event['source'] ?? null,
]);
}
}
return $verified;
}
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
private function urlReachable(string $url): array
{
$headers = ['User-Agent' => $this->verificationUserAgent()];
$headStatus = 'no-attempt';
try {
$head = Http::timeout(5)
->withHeaders($headers)
->head($url);
$headStatus = 'HEAD='.$head->status();
if ($head->successful() || $head->redirect()) {
return [true, $headStatus];
}
} catch (Throwable $e) {
$headStatus = 'HEAD=exception('.class_basename($e).')';
}
try {
$get = Http::timeout(8)
->withHeaders($headers + ['Range' => 'bytes=0-0'])
->get($url);
$getStatus = 'GET='.$get->status();
if ($get->successful() || $get->redirect()) {
return [true, $headStatus.' → '.$getStatus.' (fallback)'];
}
return [false, $headStatus.' → '.$getStatus];
} catch (Throwable $e) {
return [false, $headStatus.' → GET=exception('.class_basename($e).')'];
}
}
private function ridgeDirection(string $publicDirection): string
{
return match ($publicDirection) {
'up' => 'rising',
'down' => 'falling',
default => 'flat',
};
}
private function upcomingMondayDateString(): string
{
$today = now()->startOfDay();
$monday = $today->isMonday() ? $today : $today->copy()->next(CarbonInterface::MONDAY);
return $monday->toDateString();
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
private function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
private function model(): string
{
return config('services.anthropic.model', 'claude-haiku-4-5-20251001');
}
private function searchSystem(): string
{
return <<<'PROMPT'
You are researching news that may affect this week's UK pump-price forecast.
Search recent news (last 48 hours) for:
- OPEC+ production decisions or unexpected announcements
- Geopolitical events affecting oil supply (sanctions, conflict, shipping disruption)
- Major refinery outages or pipeline incidents
- US/EU inventory reports that materially moved Brent
Return only the search results you will be asked to summarise separately.
PROMPT;
}
private function searchUserMessage(array $context): string
{
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return "Use web_search to find oil/fuel news from the last 48 hours that could move UK pump prices this week.\n\nContext for this week:\n\n".$json;
}
private function submitSystem(): string
{
$cap = self::CONFIDENCE_CAP;
return <<<PROMPT
You are providing a news-aware directional overlay for a UK weekly pump-price forecast.
Decide whether to AGREE or DISAGREE with the ridge model based on the news headlines
provided in the user message. Cap confidence at $cap.
Include events_cited (with impact tags) for any specific headline that drove your
reasoning; you may leave events_cited empty if the news is unremarkable.
PROMPT;
}
/**
* @param array<int, array{url: string, title: string}> $harvested
*/
private function submitUserMessage(array $context, array $harvested): string
{
$contextJson = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($harvested === []) {
$headlines = '(none — no relevant news found)';
} else {
$headlines = collect($harvested)
->map(fn (array $r): string => '- '.$r['title'].' — '.$r['url'])
->implode("\n");
}
return "Context for this week:\n\n".$contextJson."\n\nNews headlines found:\n".$headlines."\n\nNow call submit_overlay with your decision.";
}
/** @return array<string, mixed> */
private function submitOverlayTool(): array
{
return [
'name' => 'submit_overlay',
'description' => 'Submit the news-aware overlay for the upcoming weekly forecast.',
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer', 'minimum' => 0, 'maximum' => self::CONFIDENCE_CAP],
'reasoning_short' => ['type' => 'string', 'description' => '12 sentences.'],
'events_cited' => [
'type' => 'array',
'description' => 'Optional. Events that drove your reasoning, with directional impact. Citations are otherwise harvested from web_search_tool_result.',
'items' => [
'type' => 'object',
'properties' => [
'headline' => ['type' => 'string'],
'source' => ['type' => 'string'],
'url' => ['type' => 'string'],
'impact' => ['type' => 'string', 'enum' => ['rising', 'falling', 'neutral']],
],
'required' => ['headline', 'source', 'url', 'impact'],
],
],
'agrees_with_ridge' => ['type' => 'boolean'],
'major_impact_event' => ['type' => 'boolean'],
],
'required' => ['direction', 'confidence', 'reasoning_short', 'agrees_with_ridge', 'major_impact_event'],
],
];
}
/**
* @param array<int, mixed> $content
* @return array<string, mixed>|null
*/
private function extractToolInput(array $content): ?array
{
$block = collect($content)->firstWhere('type', 'tool_use');
return $block['input'] ?? null;
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Services\Forecasting;
use App\Services\HaversineQuery;
use Illuminate\Support\Facades\DB;
/**
* Layer 2 descriptive snapshot of the present.
*
* Pure SQL aggregates against `station_prices_current` + Haversine on
* `stations.lat / lng`. No ML, no history, no surprises. Layer 2 never
* speaks about the future.
*
* Used by Phase 4's WeeklyForecastService to enrich the public payload
* with descriptive "your area" cards alongside the headline forecast.
*/
final class LocalSnapshotService
{
/**
* Snapshot for a coordinate (e.g. user's postcode-resolved lat/lng).
*
* @return array{
* national_avg_pence: ?float,
* local_avg_pence: ?float,
* local_minus_national_pence: ?float,
* cheapest_nearby: array<int, array{node_id: string, name: ?string, brand: ?string, price_pence: int, distance_km: float}>,
* supermarket_avg_pence: ?float,
* major_avg_pence: ?float,
* supermarket_gap_pence: ?float,
* stations_within_radius: int
* }
*/
public function snapshot(string $fuelType, float $lat, float $lng, int $radiusKm = 25): array
{
$nationalAvg = $this->nationalAverage($fuelType);
$localAvg = $this->localAverage($fuelType, $lat, $lng, 50);
$cheapest = $this->cheapestNearby($fuelType, $lat, $lng, $radiusKm, 5);
[$superAvg, $majorAvg] = $this->brandSplit($fuelType, $lat, $lng, $radiusKm);
$stationCount = $this->stationCountWithin($fuelType, $lat, $lng, $radiusKm);
return [
'national_avg_pence' => $nationalAvg,
'local_avg_pence' => $localAvg,
'local_minus_national_pence' => $localAvg !== null && $nationalAvg !== null
? round($localAvg - $nationalAvg, 1)
: null,
'cheapest_nearby' => $cheapest,
'supermarket_avg_pence' => $superAvg,
'major_avg_pence' => $majorAvg,
'supermarket_gap_pence' => $superAvg !== null && $majorAvg !== null
? round($superAvg - $majorAvg, 1)
: null,
'stations_within_radius' => $stationCount,
];
}
private function nationalAverage(string $fuelType): ?float
{
$avg = DB::table('station_prices_current')
->where('fuel_type', $fuelType)
->avg('price_pence');
return $avg === null ? null : round((float) $avg / 100, 1);
}
private function localAverage(string $fuelType, float $lat, float $lng, int $km): ?float
{
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType)
->whereRaw($within, $bindings)
->avg('station_prices_current.price_pence');
return $avg === null ? null : round((float) $avg / 100, 1);
}
/**
* @return array<int, array{node_id: string, name: ?string, brand: ?string, price_pence: int, distance_km: float}>
*/
private function cheapestNearby(string $fuelType, float $lat, float $lng, int $km, int $limit): array
{
[$distance, $distanceBindings] = HaversineQuery::distanceKm($lat, $lng);
[$within, $withinBindings] = HaversineQuery::withinKm($lat, $lng, $km);
$rows = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType)
->whereRaw($within, $withinBindings)
->selectRaw(
'stations.node_id, stations.trading_name as name, stations.brand_name as brand, '
.'station_prices_current.price_pence, '.$distance.' as distance_km',
$distanceBindings,
)
->orderBy('station_prices_current.price_pence')
->limit($limit)
->get();
return $rows->map(fn ($r): array => [
'node_id' => (string) $r->node_id,
'name' => $r->name === null ? null : (string) $r->name,
'brand' => $r->brand === null ? null : (string) $r->brand,
'price_pence' => (int) $r->price_pence,
'distance_km' => round((float) $r->distance_km, 2),
])->all();
}
/** @return array{0: ?float, 1: ?float} [supermarket_avg, major_avg] */
private function brandSplit(string $fuelType, float $lat, float $lng, int $km): array
{
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
$rows = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType)
->whereRaw($within, $bindings)
->selectRaw('stations.is_supermarket, AVG(station_prices_current.price_pence) as avg_pence')
->groupBy('stations.is_supermarket')
->get();
$super = null;
$major = null;
foreach ($rows as $r) {
$avg = round((float) $r->avg_pence / 100, 1);
if ((int) $r->is_supermarket === 1) {
$super = $avg;
} else {
$major = $avg;
}
}
return [$super, $major];
}
private function stationCountWithin(string $fuelType, float $lat, float $lng, int $km): int
{
[$within, $bindings] = HaversineQuery::withinKm($lat, $lng, $km);
return DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType)
->whereRaw($within, $bindings)
->count();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\Forecasting\Models;
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\WeeklyPrediction;
use Carbon\CarbonInterface;
/**
* Predicts ΔULSP[t+1] = 0 for every week. Direction = 'flat'.
*
* The floor any future model must beat. Per Alquist/Kilian, the
* no-change benchmark is hard to beat for short-horizon oil/fuel
* forecasts if the ridge model can't beat this, the features are wrong.
*/
final class NaiveZeroChangeModel implements WeeklyForecastModel
{
public function featureSpec(): FeatureSpec
{
return new FeatureSpec(modelLabel: 'naive-zero', features: []);
}
public function train(array $trainingMondays): void {}
public function predict(CarbonInterface $targetMonday): WeeklyPrediction
{
return new WeeklyPrediction(
targetMonday: $targetMonday,
magnitudePence: 0.0,
direction: 'flat',
);
}
public function coefficients(): ?array
{
return null;
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Services\Forecasting\Models;
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\LinearAlgebra;
use App\Services\Forecasting\WeeklyPrediction;
use App\Services\Forecasting\WeeklyPumpPriceLoader;
use Carbon\CarbonInterface;
use RuntimeException;
/**
* Ridge regression on weekly pump prices.
*
* Target: ΔULSP[t+1] = ULSP[t+1] ULSP[t], in pence × 100.
*
* Pipeline:
* - Build (X, y) from training Mondays. Skip any week where a feature
* value is null OR the actual ΔULSP cannot be computed.
* - Standardise X (z-score per column) and centre y. Keeps features
* on comparable scales so the L2 penalty is fair.
* - Solve β = (XᵀX + λI) ⁻¹ Xᵀy for the standardised problem.
* - Reconstruct intercept = mean(y) (since X is centred).
*
* Prediction:
* - Build feature vector at $targetMonday. If any feature returns
* null, predict 0 (treated as 'flat' downstream).
* - Standardise with the trained scaler, multiply by β, add intercept.
*
* Direction:
* - rising if magnitude > FLAT_THRESHOLD_PENCE_X100
* - falling if magnitude < FLAT_THRESHOLD_PENCE_X100
* - flat otherwise
*/
final class RidgeRegressionModel implements WeeklyForecastModel
{
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
/** @var array<int, float>|null Coefficients on standardised features (no intercept). */
private ?array $beta = null;
private ?float $intercept = null;
/** @var array<int, float>|null per-feature mean used for standardisation */
private ?array $featureMeans = null;
/** @var array<int, float>|null per-feature std-dev used for standardisation */
private ?array $featureStdDevs = null;
public function __construct(
private readonly FeatureSpec $spec,
private readonly WeeklyPumpPriceLoader $loader,
public readonly float $lambda = 1.0,
) {}
public function featureSpec(): FeatureSpec
{
return $this->spec;
}
public function train(array $trainingMondays): void
{
$X = [];
$y = [];
foreach ($trainingMondays as $monday) {
$row = [];
$skip = false;
foreach ($this->spec->features as $feature) {
$v = $feature->valueFor($monday);
if ($v === null) {
$skip = true;
break;
}
$row[] = $v;
}
if ($skip) {
continue;
}
$actual = $this->actualDeltaPence($monday);
if ($actual === null) {
continue;
}
$X[] = $row;
$y[] = $actual;
}
if (count($X) < count($this->spec->features) + 2) {
throw new RuntimeException('RidgeRegressionModel: insufficient training rows after dropping incomplete weeks');
}
// Standardise X (z-score) and centre y.
$featureCount = count($X[0]);
$means = array_fill(0, $featureCount, 0.0);
$stds = array_fill(0, $featureCount, 0.0);
$n = count($X);
for ($j = 0; $j < $featureCount; $j++) {
$col = array_column($X, $j);
$means[$j] = array_sum($col) / $n;
$variance = 0.0;
foreach ($col as $v) {
$variance += ($v - $means[$j]) ** 2;
}
$variance /= $n;
$stds[$j] = sqrt($variance);
// Constant features get sd=1 so we don't divide by zero. Their
// contribution is then a constant absorbed by the intercept.
if ($stds[$j] < 1e-12) {
$stds[$j] = 1.0;
}
}
$Xstd = [];
foreach ($X as $row) {
$r = [];
for ($j = 0; $j < $featureCount; $j++) {
$r[] = ($row[$j] - $means[$j]) / $stds[$j];
}
$Xstd[] = $r;
}
$yMean = array_sum($y) / $n;
$yCentred = array_map(fn (float $v): float => $v - $yMean, $y);
$this->beta = LinearAlgebra::ridgeSolve($Xstd, $yCentred, $this->lambda);
$this->intercept = $yMean;
$this->featureMeans = $means;
$this->featureStdDevs = $stds;
}
public function predict(CarbonInterface $targetMonday): WeeklyPrediction
{
if ($this->beta === null) {
throw new RuntimeException('RidgeRegressionModel: predict() called before train()');
}
$row = [];
foreach ($this->spec->features as $feature) {
$v = $feature->valueFor($targetMonday);
if ($v === null) {
return new WeeklyPrediction($targetMonday, 0.0, 'flat');
}
$row[] = $v;
}
$magnitude = $this->intercept;
for ($j = 0, $jc = count($row); $j < $jc; $j++) {
$z = ($row[$j] - $this->featureMeans[$j]) / $this->featureStdDevs[$j];
$magnitude += $z * $this->beta[$j];
}
return new WeeklyPrediction($targetMonday, $magnitude, $this->classifyDirection($magnitude));
}
public function coefficients(): ?array
{
if ($this->beta === null) {
return null;
}
$named = [];
foreach ($this->spec->features as $i => $feature) {
$named[$feature->name()] = [
'beta_standardised' => $this->beta[$i],
'mean' => $this->featureMeans[$i],
'std_dev' => $this->featureStdDevs[$i],
];
}
return [
'intercept' => $this->intercept,
'lambda' => $this->lambda,
'features' => $named,
];
}
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
{
$current = $this->loader->ulspPence($targetMonday->toDateString());
$previous = $this->loader->ulspPence($targetMonday->copy()->subDays(7)->toDateString());
if ($current === null || $previous === null) {
return null;
}
return (float) ($current - $previous);
}
private function classifyDirection(float $magnitude): string
{
return match (true) {
$magnitude > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
$magnitude < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
default => 'flat',
};
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\Forecasting;
use App\Models\WeeklyForecast;
use Illuminate\Support\Facades\DB;
/**
* Pairs a `weekly_forecasts` row with the actual ULSP move once BEIS
* publishes the matching week. Writes idempotent rows to
* `forecast_outcomes` so trailing-13-week accuracy is honest, not
* inferred.
*/
final class OutcomeResolver
{
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0;
public function resolvePending(): int
{
$resolved = 0;
$existing = DB::table('forecast_outcomes')
->select(['forecast_for', 'model_version'])
->get()
->mapWithKeys(fn ($r): array => [$r->forecast_for.'|'.$r->model_version => true])
->all();
$candidates = WeeklyForecast::query()
->where('forecast_for', '<=', now()->toDateString())
->orderBy('forecast_for')
->get();
foreach ($candidates as $forecast) {
$key = $forecast->forecast_for->toDateString().'|'.$forecast->model_version;
if (isset($existing[$key])) {
continue;
}
$actualDelta = $this->actualDeltaPence($forecast->forecast_for->toDateString());
if ($actualDelta === null) {
continue;
}
$actualClass = $this->classifyDirection($actualDelta);
$absError = (int) round(abs($forecast->magnitude_pence - $actualDelta));
DB::table('forecast_outcomes')->insert([
'forecast_for' => $forecast->forecast_for->toDateString(),
'model_version' => $forecast->model_version,
'predicted_class' => $forecast->direction,
'actual_class' => $actualClass,
'correct' => $forecast->direction === $actualClass,
'abs_error_pence' => $absError,
'resolved_at' => now(),
]);
$resolved++;
}
return $resolved;
}
private function actualDeltaPence(string $targetDate): ?float
{
$current = DB::table('weekly_pump_prices')
->where('date', $targetDate)
->value('ulsp_pence');
$previous = DB::table('weekly_pump_prices')
->where('date', date('Y-m-d', strtotime($targetDate.' -7 days')))
->value('ulsp_pence');
if ($current === null || $previous === null) {
return null;
}
return (float) ($current - $previous);
}
private function classifyDirection(float $deltaPence): string
{
return match (true) {
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
default => 'flat',
};
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services\Forecasting;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\Models\RidgeRegressionModel;
use Carbon\CarbonInterface;
/**
* Phase 6 honesty rule: the reasoning text only references features
* the model actually used, ranked by how much each contributed to
* this week's prediction.
*
* Contribution is the standardised (z-score × β) for each feature
* the same number the ridge model summed to produce the prediction.
* That makes the explanation literally what the model did, not a
* narrative invented post-hoc.
*/
final class ReasoningGenerator
{
/** @var array<string, string> */
private const array PHRASES = [
'delta_ulsp_lag_0' => "last week's pump price move",
'delta_ulsp_lag_1' => 'the pump price move two weeks ago',
'delta_ulsp_lag_3' => 'the pump price move four weeks ago',
'delta_ulsd_lag_0' => "last week's diesel move",
'ulsp_minus_ma8' => "the gap between this week's pump price and its 8-week average",
'week_of_year_sin' => 'the seasonal pattern',
'week_of_year_cos' => 'the seasonal pattern',
'is_pre_bank_holiday' => 'an upcoming bank holiday',
];
/**
* @param array<int, ForecastFeature> $features
*/
public function generate(
RidgeRegressionModel $model,
WeeklyPrediction $prediction,
array $features,
CarbonInterface $targetMonday,
int $confidence,
bool $flaggedDutyChange,
?float $trailingHitRate,
): string {
if ($confidence < 40) {
return 'Not enough signal in the historical pattern to call this week — staying silent.';
}
$coeffs = $model->coefficients() ?? [];
$features_meta = $coeffs['features'] ?? [];
$contributions = [];
foreach ($features as $f) {
$name = $f->name();
$meta = $features_meta[$name] ?? null;
if ($meta === null) {
continue;
}
$value = $f->valueFor($targetMonday);
if ($value === null) {
continue;
}
$z = ($value - $meta['mean']) / ($meta['std_dev'] ?: 1.0);
$contributions[$name] = $z * $meta['beta_standardised'];
}
$headline = $this->headline($prediction);
$driver = $this->dominantFeatureSentence($contributions);
$duty = $flaggedDutyChange
? ' Recent fuel duty change may skew accuracy for the next several weeks.'
: '';
$accuracy = $trailingHitRate !== null
? sprintf(' Last 13 weeks: %d%% hit rate.', (int) round($trailingHitRate * 100))
: '';
return $headline.' '.$driver.$duty.$accuracy;
}
private function headline(WeeklyPrediction $prediction): string
{
$absP = round(abs($prediction->magnitudePence) / 100, 1);
return match ($prediction->direction) {
'rising' => sprintf('Model expects pump prices to rise by ~%sp/L next week.', number_format($absP, 1)),
'falling' => sprintf('Model expects pump prices to fall by ~%sp/L next week.', number_format($absP, 1)),
default => 'Pump prices are likely flat next week.',
};
}
/** @param array<string, float> $contributions */
private function dominantFeatureSentence(array $contributions): string
{
if ($contributions === []) {
return 'Drawn from the full feature set with no single dominant signal.';
}
uasort($contributions, fn (float $a, float $b): int => abs($b) <=> abs($a));
$topName = array_key_first($contributions);
$phrase = self::PHRASES[$topName] ?? $topName;
return sprintf('Driver: %s.', $phrase);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Services\Forecasting;
use Carbon\Carbon;
use Carbon\CarbonInterface;
/**
* UK England-and-Wales bank holiday calendar.
*
* Computed deterministically from year (no external dependency, no
* hardcoded list to maintain).
*
* Includes the eight statutory holidays:
* New Year's Day, Good Friday, Easter Monday,
* Early May Bank Holiday, Spring Bank Holiday, Summer Bank Holiday,
* Christmas Day, Boxing Day
*
* Substitution rules: when a fixed-date holiday falls on a weekend,
* it's observed on the next non-holiday weekday (cascades for
* Christmas+Boxing landing on Sat+Sun).
*/
final class UkBankHolidays
{
/**
* Sorted list of bank holiday dates for a year, after substitution.
*
* @return array<int, Carbon>
*/
public static function forYear(int $year): array
{
$dates = [];
// Easter-anchored
[$em, $ed] = self::easter($year);
$easter = Carbon::create($year, $em, $ed);
$dates[] = $easter->copy()->subDays(2); // Good Friday
$dates[] = $easter->copy()->addDay(); // Easter Monday
// Floating Mondays
$dates[] = self::firstMondayOf($year, 5);
$dates[] = self::lastMondayOf($year, 5);
$dates[] = self::lastMondayOf($year, 8);
// Fixed dates with substitution
$dates[] = self::substituteForward(Carbon::create($year, 1, 1), $dates);
$christmas = self::substituteForward(Carbon::create($year, 12, 25), $dates);
$dates[] = $christmas;
$boxing = self::substituteForward(Carbon::create($year, 12, 26), $dates);
$dates[] = $boxing;
usort($dates, fn (CarbonInterface $a, CarbonInterface $b): int => $a->getTimestamp() <=> $b->getTimestamp());
return $dates;
}
/**
* Is there a UK bank holiday in [$from, $from + $daysAhead - 1]?
*/
public static function holidayWithin(CarbonInterface $from, int $daysAhead): bool
{
$end = $from->copy()->addDays($daysAhead - 1);
$years = array_unique([(int) $from->format('Y'), (int) $end->format('Y')]);
foreach ($years as $year) {
foreach (self::forYear($year) as $holiday) {
if ($holiday->betweenIncluded($from, $end)) {
return true;
}
}
}
return false;
}
/**
* Anonymous Gregorian algorithm for Easter Sunday.
*
* @return array{0: int, 1: int} [month, day]
*/
private static function easter(int $year): array
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return [$month, $day];
}
private static function firstMondayOf(int $year, int $month): Carbon
{
$d = Carbon::create($year, $month, 1);
while ($d->dayOfWeek !== Carbon::MONDAY) {
$d->addDay();
}
return $d;
}
private static function lastMondayOf(int $year, int $month): Carbon
{
$d = Carbon::create($year, $month, 1)->endOfMonth()->startOfDay();
while ($d->dayOfWeek !== Carbon::MONDAY) {
$d->subDay();
}
return $d;
}
/**
* If $candidate falls on a weekend or collides with an already-claimed
* date, return the next non-weekend non-claimed date. Christmas/Boxing
* cascade is handled because we pass in the running list.
*
* @param array<int, CarbonInterface> $taken
*/
private static function substituteForward(Carbon $candidate, array $taken): Carbon
{
$d = $candidate->copy();
while (true) {
$isWeekend = in_array($d->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY], true);
$isTaken = false;
foreach ($taken as $t) {
if ($t->isSameDay($d)) {
$isTaken = true;
break;
}
}
if (! $isWeekend && ! $isTaken) {
return $d;
}
$d->addDay();
}
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Services\Forecasting;
use App\Models\BrentPrice;
use App\Models\LlmOverlay;
use App\Models\VolatilityRegime;
use App\Models\WatchedEvent;
use Illuminate\Support\Facades\DB;
/**
* Layer 5 sole owner of `volatility_regimes.active`. Hourly cron.
*
* OR-combines four triggers:
* 1. Brent close-to-close move > 3% (FRED `DCOILBRENTEU`).
* 2. Most recent `llm_overlays.major_impact_event = true` AND at
* least one verified URL.
* 3. `station_prices` daily churn > 1.5× 30-day baseline. Gated
* until 180 days of polling toggleable via config.
* 4. `watched_events` row covering today.
*
* When the flag flips ON, an event-driven LLM refresh is queued
* (Layer 4 enforces its own 4h cooldown). When OFF, the row is
* closed with `flipped_off_at`.
*/
final class VolatilityRegimeService
{
private const float BRENT_MOVE_PCT = 3.0;
private const float STATION_CHURN_RATIO = 1.5;
private const int STATION_CHURN_MIN_POLLING_DAYS = 180;
public function __construct(
private readonly LlmOverlayService $llmOverlay,
) {}
public function evaluate(): ?VolatilityRegime
{
$trigger = $this->detectTrigger();
$current = VolatilityRegime::currentlyActive();
if ($trigger !== null && $current === null) {
$row = $this->flipOn($trigger);
$this->llmOverlay->run(eventDriven: true);
return $row;
}
if ($trigger === null && $current !== null) {
$this->flipOff($current);
return null;
}
return $current;
}
/** @return array{type: string, detail: string}|null */
private function detectTrigger(): ?array
{
return $this->brentMoveTrigger()
?? $this->llmEventTrigger()
?? $this->stationChurnTrigger()
?? $this->watchedEventTrigger();
}
/** @return array{type: string, detail: string}|null */
private function brentMoveTrigger(): ?array
{
$rows = BrentPrice::query()
->orderByDesc('date')
->limit(2)
->get(['date', 'price_usd']);
if ($rows->count() < 2) {
return null;
}
$latest = (float) $rows[0]->price_usd;
$prior = (float) $rows[1]->price_usd;
if ($prior === 0.0) {
return null;
}
$pctMove = abs(($latest - $prior) / $prior) * 100;
if ($pctMove <= self::BRENT_MOVE_PCT) {
return null;
}
$direction = $latest > $prior ? '+' : '-';
return [
'type' => 'brent_move',
'detail' => sprintf('Brent %s%.2f%% (%s → %s)', $direction, $pctMove, $rows[1]->date->toDateString(), $rows[0]->date->toDateString()),
];
}
/** @return array{type: string, detail: string}|null */
private function llmEventTrigger(): ?array
{
$latest = LlmOverlay::query()->orderByDesc('ran_at')->first();
if ($latest === null || ! $latest->major_impact_event) {
return null;
}
$hasVerifiedUrl = collect((array) $latest->events_json)
->contains(fn ($e): bool => is_array($e) && ! empty($e['url']));
if (! $hasVerifiedUrl) {
return null;
}
$headline = collect((array) $latest->events_json)->pluck('headline')->filter()->first();
return [
'type' => 'llm_event',
'detail' => sprintf('LLM major impact: %s', $headline ?? 'unspecified'),
];
}
/** @return array{type: string, detail: string}|null */
private function stationChurnTrigger(): ?array
{
if (! $this->stationChurnEnabled()) {
return null;
}
$oldest = DB::table('station_prices')->min('price_effective_at');
if ($oldest === null) {
return null;
}
$pollingDays = (int) abs(now()->diffInDays($oldest));
if ($pollingDays < self::STATION_CHURN_MIN_POLLING_DAYS) {
return null;
}
$last24h = (int) DB::table('station_prices')
->where('price_effective_at', '>=', now()->subDay())
->distinct('station_id')
->count('station_id');
$baseline = (int) DB::table('station_prices')
->where('price_effective_at', '>=', now()->subDays(30))
->where('price_effective_at', '<', now()->subDay())
->distinct('station_id')
->count('station_id');
if ($baseline === 0) {
return null;
}
$dailyBaseline = $baseline / 29; // 29 days of history before yesterday
if ($last24h <= $dailyBaseline * self::STATION_CHURN_RATIO) {
return null;
}
return [
'type' => 'station_churn',
'detail' => sprintf('Station churn %d/24h vs %.1f baseline (%.2fx)', $last24h, $dailyBaseline, $last24h / $dailyBaseline),
];
}
/** @return array{type: string, detail: string}|null */
private function watchedEventTrigger(): ?array
{
$row = WatchedEvent::query()
->where('starts_at', '<=', now())
->where('ends_at', '>=', now())
->orderBy('starts_at')
->first();
if ($row === null) {
return null;
}
return [
'type' => 'manual',
'detail' => sprintf('Watched event: %s', $row->label),
];
}
private function stationChurnEnabled(): bool
{
return (bool) config('services.forecasting.station_churn_enabled', false);
}
/** @param array{type: string, detail: string} $trigger */
private function flipOn(array $trigger): VolatilityRegime
{
return VolatilityRegime::query()->create([
'flipped_on_at' => now(),
'flipped_off_at' => null,
'trigger' => $trigger['type'],
'trigger_detail' => $trigger['detail'],
'active' => true,
]);
}
private function flipOff(VolatilityRegime $row): void
{
$row->update([
'flipped_off_at' => now(),
'active' => false,
]);
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace App\Services\Forecasting;
use App\Models\Backtest;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\Features\DeltaUlsdLag;
use App\Services\Forecasting\Features\DeltaUlspLag;
use App\Services\Forecasting\Features\IsPreBankHoliday;
use App\Services\Forecasting\Features\UlspMinusMa8;
use App\Services\Forecasting\Features\WeekOfYearTrig;
use App\Services\Forecasting\Models\RidgeRegressionModel;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Layer 1 orchestrates the ridge model end-to-end:
*
* 1. Builds the canonical v1 feature spec (8 features).
* 2. Trains the ridge model on every available BEIS Monday.
* 3. Predicts for the upcoming Monday.
* 4. Looks up the latest matching backtest for calibrated confidence.
* 5. Returns a flat array keyed for the existing public JSON contract.
*
* Trained-model state is cached for 1 hour (key includes model_version)
* so repeated request hits don't retrain. A new BEIS week or a feature
* spec change rolls model_version, busting the cache automatically.
*/
final class WeeklyForecastService
{
private const float DEFAULT_LAMBDA = 1.0;
public function currentForecast(): array
{
$loader = new WeeklyPumpPriceLoader;
$features = $this->buildFeatures($loader);
$spec = new FeatureSpec('ridge-v1', $features);
$cacheKey = 'forecast:current:'.$spec->modelVersion();
return Cache::remember($cacheKey, 3600, function () use ($loader, $spec, $features): array {
$model = new RidgeRegressionModel($spec, $loader, self::DEFAULT_LAMBDA);
try {
$model->train($this->collectTrainingMondays($loader));
} catch (RuntimeException) {
return $this->insufficientDataPayload($spec);
}
$targetMonday = $this->upcomingMonday();
$prediction = $model->predict($targetMonday);
$rawConfidence = $this->confidenceFromCalibration($spec, $prediction);
$flaggedDutyChange = (new DutyChangeDetector)->isAdjacent($targetMonday);
$confidence = $flaggedDutyChange ? (int) round($rawConfidence / 2) : $rawConfidence;
$directionPublic = $this->mapDirection($prediction->direction);
$action = $this->mapAction($directionPublic, $confidence);
$trailingHitRate = (new AccuracyHistory)->trailingHitRate($spec->modelVersion());
$reasoning = (new ReasoningGenerator)->generate(
$model,
$prediction,
$features,
$targetMonday,
$confidence,
$flaggedDutyChange,
$trailingHitRate,
);
$this->persistForecast($spec, $targetMonday, $prediction, $confidence, $flaggedDutyChange, $reasoning);
return [
'fuel_type' => 'e10',
'current_avg' => $this->nationalCurrentAverage(),
'predicted_direction' => $directionPublic,
'predicted_change_pence' => round($prediction->magnitudePence / 100, 1),
'confidence_score' => $confidence,
'confidence_label' => $this->confidenceLabel($confidence),
'action' => $action,
'reasoning' => $reasoning,
'prediction_horizon_days' => 7,
'region_key' => 'national',
'methodology' => 'ridge_regression_v1',
'model_version' => $spec->modelVersion(),
'flagged_duty_change' => $flaggedDutyChange,
'trailing_hit_rate' => $trailingHitRate,
'weekly_summary' => $this->weeklySummary($loader),
'signals' => $this->describeSignals($model, $prediction),
];
});
}
/**
* Build the canonical v1 feature list. Centralised here so
* WeeklyForecastService and any retraining command share the same
* spec.
*
* @return array<int, ForecastFeature>
*/
private function buildFeatures(WeeklyPumpPriceLoader $loader): array
{
return [
new DeltaUlspLag($loader, lag: 0),
new DeltaUlspLag($loader, lag: 1),
new DeltaUlspLag($loader, lag: 3),
new DeltaUlsdLag($loader, lag: 0),
new UlspMinusMa8($loader),
new WeekOfYearTrig('sin'),
new WeekOfYearTrig('cos'),
new IsPreBankHoliday,
];
}
/** @return array<int, CarbonInterface> */
private function collectTrainingMondays(WeeklyPumpPriceLoader $loader): array
{
return array_map(fn (string $d): CarbonInterface => Carbon::parse($d), $loader->allDates());
}
private function upcomingMonday(): CarbonInterface
{
$today = now()->startOfDay();
return $today->isMonday() ? $today : $today->copy()->next(Carbon::MONDAY);
}
private function confidenceFromCalibration(FeatureSpec $spec, WeeklyPrediction $prediction): int
{
$latest = Backtest::query()
->where('model_version', $spec->modelVersion())
->orderByDesc('ran_at')
->first();
if ($latest === null) {
return 0; // no backtest yet → low (gate 2 will force no_signal)
}
$table = (array) ($latest->calibration_table ?? []);
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
$hitRate = $table[$bin] ?? null;
if ($hitRate === null) {
return (int) round((float) ($latest->directional_accuracy ?? 0));
}
return (int) round(((float) $hitRate) * 100);
}
private function bucketForMagnitude(float $magnitudePence): string
{
$abs = abs($magnitudePence);
return match (true) {
$abs < 50.0 => '0.0-0.5p',
$abs < 100.0 => '0.5-1.0p',
default => '1.0p+',
};
}
private function mapDirection(string $modelDirection): string
{
return match ($modelDirection) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
}
private function mapAction(string $publicDirection, int $confidence): string
{
if ($publicDirection === 'stable' || $confidence < 40) {
return 'no_signal';
}
return $publicDirection === 'up' ? 'fill_now' : 'wait';
}
private function confidenceLabel(int $confidence): string
{
return match (true) {
$confidence >= 70 => 'high',
$confidence >= 40 => 'medium',
default => 'low',
};
}
/**
* Graceful payload when the model can't train (e.g. fresh install,
* not enough BEIS rows yet). Honest about not-knowing verdict is
* no_signal, confidence 0, reasoning explains why.
*
* @return array<string, mixed>
*/
private function insufficientDataPayload(FeatureSpec $spec): array
{
return [
'fuel_type' => 'e10',
'current_avg' => $this->nationalCurrentAverage(),
'predicted_direction' => 'stable',
'predicted_change_pence' => 0.0,
'confidence_score' => 0,
'confidence_label' => 'low',
'action' => 'no_signal',
'reasoning' => 'Not enough historical BEIS data yet to train the forecast model — staying silent until the series fills in.',
'prediction_horizon_days' => 7,
'region_key' => 'national',
'methodology' => 'ridge_regression_v1',
'model_version' => $spec->modelVersion(),
'weekly_summary' => [
'latest_publication_date' => null,
'latest_avg_pence' => null,
'prior_avg_pence' => null,
'latest_change_pence' => null,
],
'signals' => [],
];
}
private function nationalCurrentAverage(): float
{
$avg = DB::table('station_prices_current')
->where('fuel_type', 'e10')
->avg('price_pence');
return $avg === null ? 0.0 : round((float) $avg / 100, 1);
}
/** @return array<string, mixed> */
private function weeklySummary(WeeklyPumpPriceLoader $loader): array
{
$dates = $loader->allDates();
$latest = end($dates) ?: null;
$prior = $latest === null ? null : ($dates[count($dates) - 2] ?? null);
$todayPence = $latest === null ? null : $loader->ulspPence($latest);
$priorPence = $prior === null ? null : $loader->ulspPence($prior);
return [
'latest_publication_date' => $latest,
'latest_avg_pence' => $todayPence === null ? null : round($todayPence / 100, 1),
'prior_avg_pence' => $priorPence === null ? null : round($priorPence / 100, 1),
'latest_change_pence' => $todayPence !== null && $priorPence !== null
? round(($todayPence - $priorPence) / 100, 1)
: null,
];
}
/**
* Backward-compat 'signals' key. Now describes which features carried
* the most weight in this week's prediction (z-score × β contribution).
*
* @return array<string, array<string, mixed>>
*/
private function describeSignals(RidgeRegressionModel $model, WeeklyPrediction $prediction): array
{
$coeffs = $model->coefficients();
if ($coeffs === null) {
return [];
}
return [
'ridge_v1' => [
'enabled' => true,
'direction' => $prediction->direction,
'magnitude_pence' => round($prediction->magnitudePence / 100, 2),
'feature_count' => count($coeffs['features'] ?? []),
'lambda' => $coeffs['lambda'] ?? null,
],
];
}
/**
* Persist the forecast row so Phase 6's outcome resolver can pair
* it with the actual ULSP when the next BEIS week lands.
* Idempotent on (forecast_for, model_version) via UPSERT.
*/
private function persistForecast(
FeatureSpec $spec,
CarbonInterface $targetMonday,
WeeklyPrediction $prediction,
int $confidence,
bool $flaggedDutyChange,
string $reasoning,
): void {
DB::table('weekly_forecasts')->upsert(
[[
'forecast_for' => $targetMonday->toDateString(),
'model_version' => $spec->modelVersion(),
'direction' => $prediction->direction,
'magnitude_pence' => (int) round($prediction->magnitudePence),
'ridge_confidence' => max(0, min(100, $confidence)),
'flagged_duty_change' => $flaggedDutyChange,
'reasoning' => $reasoning,
'generated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]],
['forecast_for', 'model_version'],
['direction', 'magnitude_pence', 'ridge_confidence', 'flagged_duty_change', 'reasoning', 'generated_at', 'updated_at'],
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Services\Forecasting;
use Carbon\CarbonInterface;
/**
* The output of WeeklyForecastModel::predict().
*
* direction is derived from magnitudePence vs FLAT_THRESHOLD by the
* model itself, so the harness never re-derives it.
*/
final readonly class WeeklyPrediction
{
public function __construct(
public CarbonInterface $targetMonday,
public float $magnitudePence,
public string $direction,
) {}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\Forecasting;
use Illuminate\Support\Facades\DB;
/**
* Loads `weekly_pump_prices` once into an in-memory map keyed by date.
*
* Used by features and the ridge model avoids one SELECT per
* (week × feature) lookup. Lazy: nothing loads until first query.
*/
final class WeeklyPumpPriceLoader
{
/** @var array<string, object{date: string, ulsp_pence: int, ulsd_pence: int}>|null */
private ?array $byDate = null;
public function ulspPence(string $date): ?int
{
$row = $this->byDate()[$date] ?? null;
return $row === null ? null : (int) $row->ulsp_pence;
}
public function ulsdPence(string $date): ?int
{
$row = $this->byDate()[$date] ?? null;
return $row === null ? null : (int) $row->ulsd_pence;
}
/** @return array<int, string> Sorted ascending. */
public function allDates(): array
{
return array_keys($this->byDate());
}
/** @return array<string, object{date: string, ulsp_pence: int, ulsd_pence: int}> */
private function byDate(): array
{
if ($this->byDate !== null) {
return $this->byDate;
}
$rows = DB::table('weekly_pump_prices')
->orderBy('date')
->get(['date', 'ulsp_pence', 'ulsd_pence']);
$map = [];
foreach ($rows as $r) {
$map[(string) $r->date] = $r;
}
$this->byDate = $map;
return $map;
}
}

View File

@@ -124,7 +124,8 @@ class FuelPriceService
} }
$logUrl = $baseUrl.'?'.http_build_query($params); $logUrl = $baseUrl.'?'.http_build_query($params);
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::retry(3, 1000)
->timeout(60)
->withToken($token) ->withToken($token)
->get($baseUrl, $params)); ->get($baseUrl, $params));

View File

@@ -1,99 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
{
protected const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
protected readonly ApiLogger $apiLogger,
) {}
/**
* Default flow: gate on API key, call the provider, normalise the payload
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
* web-search) override `predict()` directly and reuse the helper methods.
*/
public function predict(Collection $prices): ?PricePrediction
{
$apiKey = $this->apiKey();
if ($apiKey === null) {
return null;
}
try {
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
return $payload === null ? null : $this->buildPrediction($payload);
} catch (Throwable $e) {
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
return null;
}
}
/** Returns the configured API key or null if not set. */
abstract protected function apiKey(): ?string;
/**
* Make the provider HTTP call and return the normalised payload, or null
* on failure (already logged by the implementer).
*
* @return array{direction: string, confidence: int, reasoning: string}|null
*/
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
/** @param Collection<int, BrentPrice> $prices */
protected function buildPriceList(Collection $prices): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
}
/** @param array{direction: string, confidence: int, reasoning: string} $input */
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error(static::class.': invalid direction', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'] ?? '',
'generated_at' => now(),
]);
}
protected function defaultPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -1,231 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Models\PricePrediction;
use App\Services\Ewma;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{
/**
* Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/
public function predict(Collection $prices): ?PricePrediction
{
if ($this->apiKey() === null) {
return null;
}
$prediction = $this->predictWithWebContext($prices);
return $prediction ?? $this->predictBasic($prices);
}
protected function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
/** {@inheritDoc} */
protected function callProvider(string $apiKey, string $priceList): ?array
{
return null;
}
/**
* Multi-turn web search phase, then a forced submit_prediction call.
* Phase 1: let the model search for recent oil/geopolitical news.
* Phase 2: force submit_prediction with the full conversation context.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
$messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]];
$url = 'https://api.anthropic.com/v1/messages';
try {
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
$submitResponse = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => $messages,
]));
if (! $submitResponse->successful()) {
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
return $input === null
? null
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
return null;
}
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
}
private function contextPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
First, search for recent news (last 48 hours) about:
- Brent crude oil price movements
- OPEC+ production decisions or announcements
- Major geopolitical events affecting oil supply
- Global demand signals (China economic data, US inventory reports)
Recent Brent crude prices (USD/barrel):
{$priceList}
After searching, you will be asked to submit your prediction.
PROMPT;
}
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
/** @return array{name: string, description: string, input_schema: array<string, mixed>} */
private function submitPredictionTool(): array
{
return [
'name' => 'submit_prediction',
'description' => 'Submit the final oil price direction prediction.',
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => [
'type' => 'string',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => self::LLM_MAX_CONFIDENCE,
],
'reasoning' => [
'type' => 'string',
'description' => 'One sentence explaining the prediction.',
],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
];
}
/** @param array<int, mixed> $content */
private function extractToolInput(array $content): ?array
{
$block = collect($content)->firstWhere('type', 'tool_use');
return $block['input'] ?? null;
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
{
protected function apiKey(): ?string
{
return config('services.gemini.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => $apiKey])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->defaultPrompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
],
]));
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['text' => $text]);
return null;
}
return $data;
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use Illuminate\Support\Collection;
interface OilPredictionProvider
{
/**
* Generate an oil price direction prediction from recent Brent crude prices.
* Returns null on failure, API key not configured, or insufficient data.
*
* @param Collection<int, BrentPrice> $prices Chronological Brent crude prices
*/
public function predict(Collection $prices): ?PricePrediction;
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
{
protected function apiKey(): ?string
{
return config('services.openai.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$url = 'https://api.openai.com/v1/chat/completions';
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken($apiKey)
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
],
],
],
'messages' => [[
'role' => 'user',
'content' => $this->defaultPrompt($priceList),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['data' => $data]);
return null;
}
return $data;
}
}

View File

@@ -1,414 +0,0 @@
<?php
namespace App\Services;
use App\Enums\FuelType;
use App\Models\StationPriceCurrent;
use App\Services\Prediction\Signals\BrandBehaviourSignal;
use App\Services\Prediction\Signals\DayOfWeekSignal;
use App\Services\Prediction\Signals\OilSignal;
use App\Services\Prediction\Signals\RegionalMomentumSignal;
use App\Services\Prediction\Signals\SignalContext;
use App\Services\Prediction\Signals\StickinessSignal;
use App\Services\Prediction\Signals\TrendSignal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const int PREDICTION_HORIZON_DAYS = 7;
public function __construct(
private readonly TrendSignal $trendSignal,
private readonly DayOfWeekSignal $dayOfWeekSignal,
private readonly BrandBehaviourSignal $brandBehaviourSignal,
private readonly StickinessSignal $stickinessSignal,
private readonly RegionalMomentumSignal $regionalMomentumSignal,
private readonly OilSignal $oilSignal,
) {}
/**
* @return array{
* fuel_type: string,
* current_avg: float,
* predicted_direction: string,
* predicted_change_pence: float,
* confidence_score: float,
* confidence_label: string,
* action: string,
* reasoning: string,
* prediction_horizon_days: int,
* region_key: string,
* methodology: string,
* signals: array
* }
*/
public function predict(?float $lat = null, ?float $lng = null): array
{
$fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null;
$context = new SignalContext($fuelType, $lat, $lng);
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->trendSignal->compute($context);
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
$stickiness = $this->stickinessSignal->compute($context);
$oil = $this->oilSignal->compute($context);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
$slope = $trend['slope'] ?? 0.0;
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
$confidenceLabel = match (true) {
$confidenceScore >= 70 => 'high',
$confidenceScore >= 40 => 'medium',
default => 'low',
};
$action = match ($direction) {
'up' => 'fill_now',
'down' => 'wait',
default => 'no_signal',
};
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [
'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg,
'predicted_direction' => $direction,
'predicted_change_pence' => $predictedChangePence,
'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel,
'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [
'trend' => $trend,
'day_of_week' => $dayOfWeek,
'brand_behaviour' => $brandBehaviour,
'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness,
'oil' => $oil,
],
];
}
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType->value)
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices_current.price_pence');
if ($avg !== null) {
return round((float) $avg / 100, 1);
}
}
$avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Aggregate enabled signals into a final direction + confidence score.
*
* Direction: weighted vote across signals that have a non-stable direction.
* stable signals do NOT dilute the directional vote.
*
* Confidence: weighted average of enabled signals' own confidence values,
* multiplied by an agreement coefficient (0..1) measuring how the signals
* line up with the chosen direction.
*
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
* @return array{0: string, 1: float}
*/
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{
$weights = $hasCoordinates
? [
'regionalMomentum' => 0.35,
'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.15,
'stickiness' => 0.10,
];
$directionalScoreSum = 0.0;
$directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$totalEnabledWeight += $weight;
$confidenceWeightedSum += $signal['confidence'] * $weight;
if ($signal['direction'] !== 'stable') {
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
$directionalWeightSum += $weight;
}
}
if ($totalEnabledWeight < 0.01) {
return ['stable', 0.0];
}
$normalised = $directionalWeightSum > 0.01
? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) {
$normalised >= 0.1 => 'up',
$normalised <= -0.1 => 'down',
default => 'stable',
};
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore];
}
/**
* How well the enabled signals line up with the chosen direction.
* - aligned signal: full credit (signal_confidence × weight)
* - one side stable, other directional: half credit
* - opposing signals: no credit
*
* Range: 0 (full disagreement) 1 (unanimous).
*
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{
$finalDir = match ($finalDirection) {
'up' => 1,
'down' => -1,
default => 0,
};
$credit = 0.0;
$maxCredit = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$maxCredit += $signal['confidence'] * $weight;
$signalDir = match ($signal['direction']) {
'up' => 1,
'down' => -1,
default => 0,
};
if ($signalDir === $finalDir) {
$credit += $signal['confidence'] * $weight;
} elseif ($signalDir === 0 || $finalDir === 0) {
$credit += 0.5 * $signal['confidence'] * $weight;
}
}
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
/**
* Yesterday / today / tomorrow snapshot + last-7-days series.
* Regional (50km) when coordinates are given, with national fallback when
* regional data is empty.
*
* @return array{
* yesterday_avg: ?float,
* today_avg: float,
* tomorrow_estimated_avg: ?float,
* yesterday_today_delta_pence: ?float,
* last_7_days_series: array<int, array{date: string, avg: float}>,
* last_7_days_change_pence: ?float,
* cheapest_day: ?array{date: string, avg: float},
* priciest_day: ?array{date: string, avg: float},
* is_regional: bool
* }
*/
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
{
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
$cheapestDay = null;
$priciestDay = null;
$weekChange = null;
if (count($series) >= 2) {
$byPrice = $series;
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
$cheapestDay = $byPrice[0];
$priciestDay = $byPrice[count($byPrice) - 1];
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
}
return [
'yesterday_avg' => $yesterdayAvg,
'today_avg' => $todayAvg,
'tomorrow_estimated_avg' => $tomorrowEstimated,
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
'last_7_days_series' => $series,
'last_7_days_change_pence' => $weekChange,
'cheapest_day' => $cheapestDay,
'priciest_day' => $priciestDay,
'is_regional' => $usedRegional,
];
}
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
{
$dateString = $date->toDateString();
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$regional = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->whereDate('station_prices.price_effective_at', $dateString)
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices.price_pence');
if ($regional !== null) {
return round((float) $regional / 100, 1);
}
}
$national = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->whereDate('price_effective_at', $dateString)
->avg('price_pence');
return $national !== null ? round((float) $national / 100, 1) : null;
}
/**
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
*/
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
{
$rows = collect();
$usedRegional = false;
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
$usedRegional = $rows->isNotEmpty();
}
if ($rows->isEmpty()) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
}
$series = $rows->map(fn ($r): array => [
'date' => (string) $r->day,
'avg' => round((float) $r->avg_price / 100, 1),
])->values()->all();
return [$series, $usedRegional];
}
/**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
*/
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
{
$parts = [];
if ($trend['enabled'] && abs($slope) >= self::SLOPE_THRESHOLD_PENCE) {
$parts[] = $trend['detail'];
}
if ($brandBehaviour['enabled'] && $brandBehaviour['direction'] !== 'stable') {
$parts[] = $brandBehaviour['detail'];
}
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
if (empty($parts)) {
return match ($direction) {
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
default => 'No clear pattern — fill up at the cheapest station near you now.',
};
}
return implode(' ', $parts);
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
abstract class AbstractSignal implements Signal
{
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
protected function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Least-squares linear regression. x = array index, y = value.
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
*/
protected function linearRegression(array $values): array
{
$n = count($values);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
}
$xMean = ($n - 1) / 2.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class BrandBehaviourSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class DayOfWeekSignal extends AbstractSignal
{
private const int MIN_DAYS = 21;
public function compute(SignalContext $context): array
{
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < self::MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
$parts = [];
$parts[] = abs($todayDeltaPence) < 0.1
? "Today ({$todayName}) is typically in line with the weekly average."
: sprintf(
'Today (%s) is typically %sp %s the weekly average.',
$todayName,
number_format(abs($todayDeltaPence), 1),
$todayDeltaPence > 0 ? 'above' : 'below',
);
$parts[] = abs($tomorrowDeltaPence) < 0.1
? "Tomorrow ({$tomorrowName}) is typically the same."
: sprintf(
'Tomorrow (%s) is typically %sp %s.',
$tomorrowName,
number_format(abs($tomorrowDeltaPence), 1),
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
);
if ($cheapestDow === $todayDow) {
$parts[] = 'Today is historically the cheapest day of the week.';
}
return [
'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction,
'detail' => implode(' ', $parts),
'data_points' => $uniqueDays,
'enabled' => true,
];
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
/**
* SQL dialect helpers for the small set of MySQL/SQLite differences the
* signal classes care about. Centralises the isSqlite ternaries that were
* duplicated across DayOfWeekSignal and StickinessSignal.
*/
final class DbDialect
{
private static function isSqlite(): bool
{
return DB::connection()->getDriverName() === 'sqlite';
}
/**
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
* Targets a column on the queried table.
*/
public static function dayOfWeekExpr(string $column): string
{
return self::isSqlite()
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
: "DAYOFWEEK({$column})";
}
/**
* Whole-day difference between MAX and MIN of a datetime column, suitable
* for use in an aggregate selectRaw.
*/
public static function maxMinDayDiffExpr(string $column): string
{
return self::isSqlite()
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
: "DATEDIFF(MAX({$column}), MIN({$column}))";
}
}

View File

@@ -1,63 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class OilSignal extends AbstractSignal
{
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
* fallback) covering today or later. Sourced from price_predictions,
* which OilPriceService populates daily.
*/
public function compute(SignalContext $context): array
{
$prediction = null;
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
$prediction = DB::table('price_predictions')
->where('source', $source)
->where('predicted_for', '>=', now()->toDateString())
->orderByDesc('predicted_for')
->orderByDesc('generated_at')
->first();
if ($prediction !== null) {
break;
}
}
if ($prediction === null) {
return $this->disabledSignal('No oil price prediction available');
}
$direction = match ($prediction->direction) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
$score = match ($direction) {
'up' => 1.0,
'down' => -1.0,
default => 0.0,
};
$confidence = round(((float) $prediction->confidence) / 100, 2);
return [
'score' => $score,
'confidence' => $confidence,
'direction' => $direction,
'detail' => sprintf(
'Brent crude %s (%s, %d%% confidence)',
$prediction->direction,
$prediction->source,
(int) $prediction->confidence,
),
'data_points' => 1,
'enabled' => true,
];
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Services\HaversineQuery;
use Illuminate\Support\Facades\DB;
final class RegionalMomentumSignal extends AbstractSignal
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float REGIONAL_RADIUS_KM = 50.0;
public function compute(SignalContext $context): array
{
if (! $context->hasCoordinates()) {
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
}
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
interface Signal
{
/**
* Evaluate the signal against the given context.
*
* Returns the canonical signal payload. Implementations may add extra
* keys beyond the base shape (e.g. trend adds slope + r_squared).
*
* @return array{
* score: float,
* confidence: float,
* direction: string,
* detail: string,
* data_points: int,
* enabled: bool,
* ...
* }
*/
public function compute(SignalContext $context): array;
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Enums\FuelType;
/**
* Inputs required to evaluate a prediction signal. Individual signals may
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
* RegionalMomentumSignal requires lat/lng to be non-null.
*/
final readonly class SignalContext
{
public function __construct(
public FuelType $fuelType,
public ?float $lat = null,
public ?float $lng = null,
) {}
public function hasCoordinates(): bool
{
return $this->lat !== null && $this->lng !== null;
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class StickinessSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -1,86 +0,0 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class TrendSignal extends AbstractSignal
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float SLOPE_SATURATION_PENCE = 0.5;
private const int PREDICTION_HORIZON_DAYS = 7;
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
public function compute(SignalContext $context): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
}

View File

@@ -7,8 +7,9 @@ use App\Enums\PriceReliability;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User; use App\Models\User;
use App\Services\Forecasting\LocalSnapshotService;
use App\Services\Forecasting\WeeklyForecastService;
use App\Services\HaversineQuery; use App\Services\HaversineQuery;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures; use App\Services\PlanFeatures;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -17,7 +18,8 @@ use Illuminate\Support\Collection;
final class StationSearchService final class StationSearchService
{ {
public function __construct( public function __construct(
private readonly NationalFuelPredictionService $predictionService, private readonly WeeklyForecastService $weeklyForecast,
private readonly LocalSnapshotService $localSnapshot,
) {} ) {}
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
@@ -134,7 +136,10 @@ final class StationSearchService
*/ */
private function buildPrediction(?User $user, SearchCriteria $criteria): array private function buildPrediction(?User $user, SearchCriteria $criteria): array
{ {
$result = $this->predictionService->predict($criteria->lat, $criteria->lng); $result = $this->weeklyForecast->currentForecast();
// Layer 1 is national; the region_key only reflects whether the
// caller passed coordinates so the JSON contract stays stable.
$result['region_key'] = 'regional';
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions'); $canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
@@ -146,6 +151,13 @@ final class StationSearchService
]; ];
} }
$result['local_snapshot'] = $this->localSnapshot->snapshot(
fuelType: $criteria->fuelType->value,
lat: $criteria->lat,
lng: $criteria->lng,
radiusKm: max(10, (int) $criteria->radiusKm),
);
return $result; return $result;
} }
} }

View File

@@ -1,9 +1,9 @@
{ {
"$schema": "https://getcomposer.org/schema.json", "$schema": "https://getcomposer.org/schema.json",
"name": "laravel/livewire-starter-kit", "name": "fuel-alert/fuel-alert",
"type": "project", "type": "project",
"description": "The official Laravel starter kit for Livewire.", "description": "Fuel Alert — UK fuel price intelligence app.",
"keywords": ["laravel", "framework"], "keywords": ["laravel", "fuel", "alerts"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.4", "php": "^8.4",

View File

@@ -68,6 +68,13 @@ return [
'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'), 'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'),
], ],
'forecasting' => [
// Phase 9 station-churn trigger is gated until ≥180 days of stable
// polling. Flip on once `station_prices` has continuous coverage —
// see `.claude/rules/forecasting.md`.
'station_churn_enabled' => env('FORECASTING_STATION_CHURN_ENABLED', false),
],
'fuelalert' => [ 'fuelalert' => [
'api_key' => env('FUELALERT_API_KEY'), 'api_key' => env('FUELALERT_API_KEY'),
], ],

View File

@@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Backtest;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Backtest> */
class BacktestFactory extends Factory
{
public function definition(): array
{
return [
'model_version' => 'test-'.fake()->unique()->bothify('????????'),
'features_json' => ['features' => ['delta_ulsp_lag_0']],
'coefficients_json' => null,
'train_start' => '2018-01-01',
'train_end' => '2024-01-01',
'eval_start' => '2024-01-08',
'eval_end' => '2026-04-27',
'directional_accuracy' => fake()->randomFloat(2, 50, 75),
'mae_pence' => fake()->randomFloat(2, 0.4, 1.0),
'calibration_table' => ['0.0-0.5' => 0.55, '0.5-1.0' => 0.65, '1.0+' => 0.72],
'leak_suspected' => false,
'ran_at' => now(),
];
}
}

View File

@@ -80,7 +80,7 @@ class PlanFactory extends Factory
'whatsapp_daily_limit' => 5, 'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2, 'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true, 'sms_enabled' => true,
'sms_daily_limit' => 1, 'sms_daily_limit' => 3,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,

Some files were not shown because too many files have changed in this diff Show More