Compare commits

..

34 Commits

Author SHA1 Message Date
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
- 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
Ovidiu U
25cf022964 feat: add prediction rebuild design spec — Layer 1 ridge model, LLM news overlay, volatility regime detector
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
Documents complete replacement of six-signal aggregator with calibrated
ridge forecaster trained on 435 weeks of BEIS pump prices. Five-layer
architecture: weekly baseline (Layer 1), local snapshot (Layer 2),
rule-gated verdict merger (Layer 3), daily LLM news
2026-05-01 13:23:10 +01:00
Ovidiu U
e821a934a5 feat: add weekly_pump_prices migration for BEIS fuel price data
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
Created migration for storing UK weekly pump prices from BEIS publications.
Table uses Monday date as primary key and stores petrol/diesel pump prices,
duty, and VAT rates as integer pence or percentage values.
2026-05-01 13:22:50 +01:00
Ovidiu U
73de53994f fix: prevent sensitive field leaks in /me, add retry logic to Brent price sources
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
- Made `/api/auth/me` public and return explicit allowlist (name, email,
  two_factor_confirmed_at, tier, subscription fields) instead of spreading
  `$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
  postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
  FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
  raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
  instead of silently returning null + logging. Updated tests to assert
  exception message includes HTTP status or "connection failed".
2026-05-01 13:22:36 +01:00
Ovidiu U
df70e514e9 refactor: add hard-stop documentation and deny-list for destructive DB commands
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
Documented explicit prohibition of `migrate:fresh`, `migrate:reset`,
`db:wipe`, and raw DROP/TRUNCATE operations in CLAUDE.md. Prose rule
clarifies that user phrases like "trust me" or "do the refactor" are
not authorisation for schema rebuilds — architectural decision is
separate from operational step.

Added matching deny patterns to `.claude/settings.json` to block
direct inv
2026-04-30 09:01:20 +01:00
Ovidiu U
28061541d4 refactor: remove auto-scroll on stations loading
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
Disabled the watch hook that scrolled the viewport when station results
were loading. The 40px nudge disrupted user focus and was not needed
for usability.
2026-04-30 09:01:11 +01:00
Ovidiu U
895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00
Ovidiu U
aff6dd1e0f refactor: SendScheduledWhatsAppJob — drop redundant filtering
Audit item #12. The fan-out job ran an upfront Plan query plus a
per-user tier-name comparison before checking canSendNow('whatsapp').
Both are already covered by canSendNow → canUseChannel + daily-limit
count, so the parent was duplicating filtering work that the child
DispatchUserNotificationJob would do anyway via channelsFor().

Now the parent does only the cheap pre-check (canSendNow) before
dispatching the per-user child job. Iteration uses chunkById(500) to
make the memory bound explicit. Each user remains its own queueable
unit — independent retry, no shared failure mode across the cohort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:30 +01:00
Ovidiu U
06f5f2035f refactor: extract iterateBatches helper in FuelPriceService
Audit item #9. pollPrices() and refreshStations() shared the same
do/while batch loop with token + try/catch + Log::error + clean-exit
detection but different bodies. Extracted to a private iterateBatches()
that takes the endpoint, optional since timestamp, and a process
callable. Returns [total_processed, completedCleanly] so pollPrices
can still gate its incremental-poll cache update on a clean exhaustion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:21 +01:00
Ovidiu U
69eb524e07 fix: ImportPostcodes streams into staging, swaps on success
Audit item #13. The command truncated postcodes + outcodes immediately
and then chunk-inserted from the CSV. A mid-stream failure (network
drive disconnect, malformed row) left the production tables empty
until a successful re-run.

Now streams into a postcodes_staging table created via Schema::create
(works on both MySQL and SQLite) and only swaps into the live tables
once the full CSV has been consumed. A try/finally ensures the file
handle and staging table are cleaned up on any path. Live data is now
preserved on partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:15 +01:00
Ovidiu U
b4ef1177b2 refactor: PollFuelPricesJob calls service directly
Audit item #11. The job was a one-line Artisan::call wrapper around
fuel:poll --full. Routing through Artisan adds output buffering and
swallows typed exceptions before they reach the queue's failed-job
handler. Now injects FuelPriceService and replicates the --full path
(refreshStations + pollPrices + PricesUpdatedEvent) directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:05 +01:00
Ovidiu U
8e29980dfe perf: memoize PriceReliability + PriceClassification per row
Audit item #10. PriceReliability::fromUpdatedAt was being invoked
~10× per station per /api/stations response — twice in the sort
comparator (once for $a, once for $b), once in the count groupBy, and
once per resource render. PriceClassification::fromUpdatedAt was
called twice inside the resource (value + label).

The controller now computes the parsed datetime + reliability +
classification once per row and stashes them on the row. Sort,
groupBy, and StationResource read the cached values; the resource
keeps a fresh-compute fallback for callers that bypass the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:59 +01:00
Ovidiu U
4ce5066596 refactor: persist EWMA only on LLM failure, dedup EWMA helper
Audit items #7 and #5.

#7 — BrentPricePredictor::generatePrediction previously wrote both an
EWMA row and an LLM row to price_predictions on every run. The
downstream OilSignal already prefers llm_with_context > llm > ewma, so
the EWMA row was dead weight 95% of the time. Now we try LLM first; if
it returns null (no API key, parse failure, etc.) we compute and persist
EWMA as a real fallback. This also avoids redundant work on the success
path.

Updated the "stores both" test to "stores only LLM" — asserts no EWMA
row is written when the provider succeeds.

#5 — BrentPricePredictor and AnthropicPredictionProvider both had
byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3
constants. Extracted to App\Services\Ewma::compute() and dropped both
private methods + their alpha constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:41 +01:00
Ovidiu U
c46b017b51 chore: audit nits — PlanFeatures, test boot, EIA log, static method
Audit items #15, #16, #20, #22.

#15 — AuthController::me and UserResource form/table now read tier via
PlanFeatures::for($user)->tier() instead of Plan::resolveForUser($user)
->name. Tiers.md: PlanFeatures is the single entitlement gate.

#16 — Moved SQLite GREATEST/LEAST PHP-backed function registration from
AppServiceProvider::boot to tests/TestCase::setUp. Production app boot no
longer checks the DB driver name.

#20 — FetchOilPrices: added Log::warning on EIA fallback and Log::error
on both-providers-failed so primary-source reliability can be trended
beyond the cron output buffer.

#22 — FuelPriceService::flattenEnabledFlags is now an instance method,
matching the rest of the class. No external callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:09 +01:00
Ovidiu U
7f64c42a23 refactor: extract DbDialect helper, inline ProfileValidationRules trait
Audit items #17 and #21.

#17 — DayOfWeekSignal and StickinessSignal each had their own
isSqlite ternary picking between SQLite (strftime/julianday) and
MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in
App\Services\Prediction\Signals\DbDialect.

#21 — ProfileValidationRules was a trait with one consumer
(CreateNewUser); inlined the rules into the action and deleted the
trait. Also dropped PasswordValidationRules::currentPasswordRules()
which was unused. PasswordValidationRules trait stays (two consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:01 +01:00
Ovidiu U
4d9df1ee19 chore: add OneSignal and Vonage env keys to .env.example
Follow-up to the FuelPriceAlert notification wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:21 +01:00
Ovidiu U
5369b4a5a0 feat: build FuelPriceAlert notification with multi-channel adapters
The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.

- App\Notifications\FuelPriceAlert — Notification class with via() that
  returns the per-tier-filtered channel list passed in by the dispatcher.
  Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
  ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
  REST API, gated on services.onesignal.{app_id,api_key} + user
  push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
  Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
  API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
  FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
  channels, and that nothing is dispatched when no channels are allowed.

Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.

No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:10 +01:00
Ovidiu U
27c82ef103 refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal
methods, a private linearRegression helper, and a private disabledSignal
shape factory all crammed together. Each signal is now an independently
testable class.

- App\Services\Prediction\Signals\Signal — interface
- App\Services\Prediction\Signals\SignalContext — input value object
  (FuelType + optional lat/lng + hasCoordinates() helper)
- App\Services\Prediction\Signals\AbstractSignal — shared
  disabledSignal() and linearRegression() helpers
- TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal,
  RegionalMomentumSignal, OilSignal — one class each, extending
  AbstractSignal

NationalFuelPredictionService receives the 6 signal classes via constructor
injection and orchestrates them. The lat/lng null-guard for regional
momentum now lives inside RegionalMomentumSignal::compute() so the
coordinator no longer branches on coordinate presence.

Aggregation, weekly summary, and reasoning helpers stay in the service
for now — they are coupled to the public predict() output shape and are
candidates for a follow-up extraction once a stable API is locked in.

Service: 803 → 414 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:43:28 +01:00
Ovidiu U
e39618f5df refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate,
chronological price-list building, response validation
(direction/confidence/reasoning), TrendDirection::tryFrom, confidence
cap at 85, and the top-level try/catch + Log::error.

Now in AbstractLlmPredictionProvider:
- LLM_MAX_CONFIDENCE constant
- buildPriceList(Collection) helper
- buildPrediction(input, ?source) — handles direction validation,
  confidence cap, model construction
- defaultPrompt(priceList) — shared by Gemini and OpenAi
- Default predict() flow (apiKey + callProvider + buildPrediction +
  try/catch). Gemini and OpenAi only implement apiKey() and
  callProvider(). Anthropic overrides predict() because of its
  multi-phase web-search + forced-tool flow but reuses the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:35:57 +01:00
Ovidiu U
00d0f7c8ec refactor: extract HaversineQuery helper, fix LL bind quirk
The 5 haversine SQL fragments duplicated across StationController and
NationalFuelPredictionService disagreed on float-clamping (LEAST only,
GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery
with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere.

withinKm() embeds the radius as a numeric literal (sprintf %F) because
PDO + SQLite binds float parameters as strings by default, which breaks
numeric comparison against the haversine expression — a NULL filter would
silently match all rows. Coordinates remain bound positionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:07 +01:00
Ovidiu U
48af2083f3 feat: add fuel:archive command and monthly scheduler entry
Completes Tasks 12 + 13 from docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md.

- ArchiveOldPricesCommand moves station_prices rows older than 12 months
  into station_prices_archive in chunks of 1000, wrapping each chunk's
  insert + delete in a DB::transaction so a partial failure can't
  duplicate rows.
- StationPriceArchive: add $table = 'station_prices_archive'; without it
  Eloquent infers 'station_price_archives' and the insert would fail.
- routes/console.php: register fuel:archive monthly on the 1st at 04:00
  UTC, alongside the other fuel/oil scheduler entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:05 +01:00
Ovidiu U
783297694c fix: model audit cleanups (primaryKey, fuel_type cast, cadence cache)
- StationPriceCurrent: $primaryKey was null; set to 'station_id' + keyType
  string so Eloquent has a sensible default for save() / find() paths.
- UserNotificationPreference: add FuelType enum cast on fuel_type so it
  hydrates as an enum like every other price model.
- Plan::resolveCadenceForUser: cache for 1h under the same plans tag as
  resolveForUser; HandleStripeWebhook busts both keys on subscription
  events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:32:55 +01:00
Ovidiu U
775e076bb7 Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
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 current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00
Ovidiu U
8695d5ec95 refactor: flatten plans.features JSON to typed columns
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.

PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.

tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +01:00
188 changed files with 11658 additions and 3674 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored

Binary file not shown.

211
.claude/rules/prediction.md Normal file
View File

@@ -0,0 +1,211 @@
# Prediction Engine
The "should I fill up now or wait?" recommendation that drives the headline,
notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php`
and is called from `Api\PredictionController`.
> The prediction is the product's selling point. Confidence calibration matters
> as much as direction — a "Wait — prices falling" headline at 30% confidence is
> worse than no recommendation at all.
## Output
`predict(?float $lat, ?float $lng): array` returns:
| Key | Type | Notes |
|---|---|---|
| `fuel_type` | string | currently always `'e10'` |
| `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) |
| `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote |
| `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon |
| `confidence_score` | float (0100) | see "Confidence" below |
| `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` |
| `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction |
| `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback |
| `prediction_horizon_days` | int | `7` |
| `region_key` | `'national' | 'regional'` | depends on whether coords were passed |
| `methodology` | string | identifier for backtesting/auditing |
| `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) |
| `signals` | object | per-signal breakdown (see below) |
## Signals
Each signal returns `{score, confidence, direction, detail, data_points, enabled}`.
| Signal | Source | Enabled when | Score formula |
|---|---|---|---|
| `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) |
| `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` |
| `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down |
| `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` |
| `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier |
| `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` |
| `national_momentum` | reserved | always disabled today | n/a |
### Oil signal — source preference
`computeOilSignal()` picks the freshest row in this order:
1. `source = 'llm_with_context'`
2. `source = 'llm'`
3. `source = 'ewma'`
`OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates
this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85,
EWMA at 65 (see `.claude/rules/api-data.md`).
The Brent oil signal is the **single biggest unlock** for confidence — it
captures world-news context (OPEC, geopolitical) that pure local price history
can't see.
### Day-of-week threshold
The original spec said 56 days. Lowered to 21 because:
- The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day
signal naturally contributes only `~0.23` confidence and lifts as more data
accumulates.
- 56 days delays the signal so long it might as well not exist for new users.
## Aggregator
`aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`.
### Weights
```
National (no coords):
trend 0.30
oil 0.25
dayOfWeek 0.20
brandBehaviour 0.15
stickiness 0.10
----
1.00
Regional (with coords):
regionalMomentum 0.35
oil 0.20
trend 0.15
dayOfWeek 0.15
brandBehaviour 0.10
stickiness 0.05
----
1.00
```
### Direction
```
directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable
directional_weight = Σ(weight) // only signals with direction ≠ stable
normalised = directional_score / directional_weight (0 if directional_weight ≈ 0)
direction = 'up' if normalised >= 0.1
'down' if normalised <= -0.1
'stable' otherwise
```
**Stable signals do not dilute the direction vote.** They are excluded from both
the numerator and denominator. This is a key fix — previously a single weak
trend signal could be cancelled out by three "stable" signals adding weight
without contributing direction.
### Confidence
```
avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals
agreement = computeAgreement(signals, weights, final_direction) // 0..1
confidence_score = avg_signal_confidence × agreement × 100 (capped at 100)
```
**`avg_signal_confidence`** is how confident the individual signals are in
their own readings (R², sample size, model confidence). Stable signals DO
contribute here — knowing prices are stable is itself a confident answer.
**`agreement`** measures how the 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
- final score: `Σ credit / Σ max_credit`
This separation is the second key fix. Previously `confidence = |normalised| × 100`
conflated "the signals point strongly somewhere" with "we're sure". Now:
- Strong signals all agreeing → high `confidence_score`
- Strong signals disagreeing → low `confidence_score`
- Weak signals → low `confidence_score` (via low individual confidences)
### Confidence labels
| `confidence_score` | `confidence_label` | UI behaviour |
|---|---|---|
| ≥ 70 | `high` | fire notification when allowed |
| 4069 | `medium` | dashboard only |
| < 40 | `low` | dashboard only |
## Reasoning
`buildReasoning()` joins `detail` strings from enabled signals. If none have
material content, it falls back to an **action-aware** sentence:
| `direction` / `action` | Fallback |
|---|---|
| `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." |
| `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." |
| `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." |
The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling"
headlines and is no longer used.
## Weekly summary
`computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers:
| Field | Meaning |
|---|---|
| `yesterday_avg` / `today_avg` | regional (50km) → national fallback |
| `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) |
| `yesterday_today_delta_pence` | `today yesterday`; sign tells you which was cheaper |
| `last_7_days_series` | array of `{date, avg}`, one entry per day with data |
| `last_7_days_change_pence` | `series[last].avg series[0].avg` |
| `cheapest_day` / `priciest_day` | min/max of the series |
| `is_regional` | `true` only if regional data was actually used; `false` after national fallback |
## API gate
The prediction is **embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint. The same payload
shape ships back regardless of route, but the gate runs server-side:
`PlanFeatures::for($user)->can('ai_predictions')`.
- ai_predictions allowed (plus, pro): full multi-signal payload
(`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`,
`reasoning`, `weekly_summary`, `signals`, …)
- otherwise (free, basic, guest): stripped teaser
`{fuel_type, predicted_direction, tier_locked: true}` for the upsell card
Bundling into `/api/stations` ties prediction availability to a real station
search — there is no way to scrape the prediction independently. Don't add a
separate prediction route or accept a request body without coords; the
prediction is always computed alongside a search.
## What never to do
- Don't introduce a new signal without giving it `enabled`, `confidence`, and a
weight in both national + regional weight maps.
- Don't read `brent_prices` directly from the prediction service — go through
`price_predictions`. The prediction table is the source of truth for
oil-direction-as-a-signal.
- Don't reintroduce a confidence formula that uses `|directional_score|` — that
conflates magnitude with sureness.
- Don't add a stable-direction signal to `directional_weight` — stable signals
must not dilute direction.
---
paths:
- "app/Services/NationalFuelPredictionService.php"
- "app/Http/Controllers/Api/StationController.php"
- "tests/Unit/Services/NationalFuelPredictionServiceTest.php"
- "tests/Feature/Api/StationControllerTest.php"
---

30
.claude/settings.json Normal file
View File

@@ -0,0 +1,30 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(.env)",
"Bash(cat .env)",
"Bash(cat ./.env)",
"Bash(head .env)",
"Bash(head ./.env)",
"Bash(tail .env)",
"Bash(tail ./.env)",
"Bash(less .env)",
"Bash(less ./.env)",
"Bash(more .env)",
"Bash(more ./.env)",
"Bash(grep * .env)",
"Bash(grep * ./.env)",
"Bash(rg * .env)",
"Bash(rg * ./.env)",
"Bash(awk * .env)",
"Bash(awk * ./.env)",
"Bash(php artisan migrate:fresh)",
"Bash(php artisan migrate:fresh *)",
"Bash(php artisan migrate:reset)",
"Bash(php artisan migrate:reset *)",
"Bash(php artisan db:wipe)",
"Bash(php artisan db:wipe *)"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Fuel Alert"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://fuel-alert.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=fuel-price
DB_USERNAME=fuel-price
DB_PASSWORD=password
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_DOMAIN=.fuel-alert.test
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -64,19 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
FUELALERT_API_KEY=
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY=
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=FuelAlert
API_SECRET_KEY=
EIA_API_KEY=
LLM_PREDICTION_PROVIDER=anthropic
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=gbp
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_ANNUAL=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL=
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-alert.test

1
.gitignore vendored
View File

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

View File

@@ -3,6 +3,20 @@
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer.
## 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:
- `php artisan migrate:fresh` (with any flags, including `--seed`)
- `php artisan migrate:reset`
- `php artisan db:wipe`
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
- Any sequence that effectively rebuilds the schema or drops tables
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
## Project overview
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers

View File

@@ -3,14 +3,14 @@
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
'password' => $this->passwordRules(),
])->validate();

View File

@@ -16,14 +16,4 @@ trait PasswordValidationRules
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ArchiveOldPricesCommand extends Command
{
protected $signature = 'fuel:archive';
protected $description = 'Move station price history older than 12 months to the archive table';
public function handle(): int
{
$cutoff = Carbon::now()->subMonths(12);
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
if ($count === 0) {
$this->info('No prices to archive.');
return self::SUCCESS;
}
$this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}...");
StationPrice::where('price_effective_at', '<', $cutoff)
->chunkById(1000, function ($prices): void {
$rows = $prices->map(fn (StationPrice $price): array => [
'station_id' => $price->station_id,
'fuel_type' => $price->fuel_type->value,
'price_pence' => $price->price_pence,
'price_effective_at' => $price->price_effective_at,
'price_reported_at' => $price->price_reported_at,
'recorded_at' => $price->recorded_at,
])->all();
DB::transaction(function () use ($rows, $prices): void {
StationPriceArchive::insert($rows);
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
});
});
$this->info('Archive complete.');
return self::SUCCESS;
}
}

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

@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
#[Signature('oil:fetch')]
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
}
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
$this->error('Both EIA and FRED failed: '.$e->getMessage());
return self::FAILURE;

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

@@ -5,7 +5,10 @@ namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
@@ -79,56 +82,78 @@ final class ImportPostcodes extends Command
$hasDoterm = isset($columns['doterm']);
DB::table('postcodes')->truncate();
DB::table('outcodes')->truncate();
// Stream into a staging table first. Only swap into the live
// postcodes / outcodes tables once the full CSV has been consumed —
// a mid-stream failure leaves production data untouched.
Schema::dropIfExists('postcodes_staging');
Schema::create('postcodes_staging', function (Blueprint $table): void {
$table->string('postcode', 7);
$table->string('outcode', 4);
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
$buffer = [];
$imported = 0;
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
try {
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
}
$lat = trim((string) ($row[$columns['lat']] ?? ''));
$lng = trim((string) ($row[$columns['long']] ?? ''));
if ($lat === '' || $lng === '') {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
if ($pcd === '' || strlen($pcd) < 5) {
continue;
}
$buffer[] = [
'postcode' => $pcd,
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
'lat' => (float) $lat,
'lng' => (float) $lng,
];
if (count($buffer) >= self::CHUNK_SIZE) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
$buffer = [];
}
}
$lat = trim((string) ($row[$columns['lat']] ?? ''));
$lng = trim((string) ($row[$columns['long']] ?? ''));
if ($lat === '' || $lng === '') {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
if ($pcd === '' || strlen($pcd) < 5) {
continue;
}
$buffer[] = [
'postcode' => $pcd,
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
'lat' => (float) $lat,
'lng' => (float) $lng,
];
if (count($buffer) >= self::CHUNK_SIZE) {
DB::table('postcodes')->insert($buffer);
if ($buffer !== []) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
$buffer = [];
}
// Swap: empty live tables, copy from staging, derive outcodes.
DB::table('outcodes')->truncate();
DB::table('postcodes')->truncate();
DB::statement(
'INSERT INTO postcodes (postcode, outcode, lat, lng)
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
} catch (Throwable $e) {
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
return self::FAILURE;
} finally {
fclose($handle);
Schema::dropIfExists('postcodes_staging');
}
if ($buffer !== []) {
DB::table('postcodes')->insert($buffer);
$imported += count($buffer);
}
fclose($handle);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
$this->info("Imported {$imported} postcodes.");
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');

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

@@ -16,7 +16,7 @@ class PlanForm
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('features.fuel_types.max')
TextInput::make('max_fuel_types')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
@@ -28,9 +28,9 @@ class PlanForm
Section::make('Email')
->columns(2)
->schema([
Toggle::make('features.email.enabled')
Toggle::make('email_enabled')
->label('Enabled'),
Select::make('features.email.frequency')
Select::make('email_frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
@@ -42,9 +42,9 @@ class PlanForm
Section::make('Push')
->columns(2)
->schema([
Toggle::make('features.push.enabled')
Toggle::make('push_enabled')
->label('Enabled'),
Select::make('features.push.frequency')
Select::make('push_frequency')
->label('Frequency')
->options([
'none' => 'None (disabled)',
@@ -56,15 +56,15 @@ class PlanForm
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('features.whatsapp.enabled')
Toggle::make('whatsapp_enabled')
->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit')
TextInput::make('whatsapp_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('features.whatsapp.scheduled_updates')
TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
@@ -75,9 +75,9 @@ class PlanForm
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('features.sms.enabled')
Toggle::make('sms_enabled')
->label('Enabled'),
TextInput::make('features.sms.daily_limit')
TextInput::make('sms_daily_limit')
->label('Daily limit')
->numeric()
->integer()
@@ -87,11 +87,11 @@ class PlanForm
Section::make('Features')
->schema([
Toggle::make('features.ai_predictions')
Toggle::make('ai_predictions')
->label('AI predictions'),
Toggle::make('features.price_threshold')
Toggle::make('price_threshold')
->label('Price threshold alerts'),
Toggle::make('features.score_alerts')
Toggle::make('score_alerts')
->label('Score change alerts'),
]),
]);

View File

@@ -17,16 +17,16 @@ class PlansTable
->label('Tier')
->badge()
->sortable(),
TextColumn::make('features.email.frequency')
TextColumn::make('email_frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('features.sms.daily_limit')
TextColumn::make('sms_daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit')
TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('features.fuel_types.max')
TextColumn::make('max_fuel_types')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
@@ -75,7 +76,7 @@ class UserResource extends Resource
->live()
->dehydrated(false)
->afterStateHydrated(fn (Select $component, ?User $record) => $component
->state($record ? Plan::resolveForUser($record)->name : PlanTier::Free->value)),
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
Select::make('cadence')
->label('Billing Cadence')
->options([
@@ -131,7 +132,7 @@ class UserResource extends Resource
TextColumn::make('postcode')->placeholder('—'),
TextColumn::make('tier')
->label('Tier')
->state(fn (User $record): string => Plan::resolveForUser($record)->name)
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
->badge()
->colors([
'gray' => 'free',

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

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -63,19 +64,24 @@ class AuthController extends Controller
public function me(Request $request): JsonResponse
{
$user = $request->user();
if ($user === null) {
return new JsonResponse('null', json: true);
}
$subscription = $user->subscription();
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
return response()->json(array_merge(
$user->toArray(),
[
'tier' => Plan::resolveForUser($user)->name,
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
],
));
return response()->json([
'name' => $user->name,
'email' => $user->email,
'two_factor_confirmed_at' => $user->two_factor_confirmed_at?->toIso8601String(),
'tier' => PlanFeatures::for($user)->tier(),
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
]);
}
}

View File

@@ -2,29 +2,60 @@
namespace App\Http\Controllers\Api;
use App\Enums\PriceReliability;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
use App\Models\User;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause;
use App\Services\StationSearch\SearchCriteria;
use App\Services\StationSearch\StationSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
class StationController extends Controller
{
public function __construct(
private readonly PostcodeService $postcodeService,
private readonly NationalFuelPredictionService $predictionService,
private readonly StationSearchService $searchService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse
{
[$lat, $lng] = $this->resolveCoordinates($request);
$criteria = new SearchCriteria(
lat: $lat,
lng: $lng,
fuelType: $request->fuelType(),
radiusKm: $request->radius(),
sort: $request->sort(),
);
$result = $this->searchService->search(
$criteria,
$request->user(),
hash('sha256', $request->ip() ?? ''),
);
return response()->json([
'data' => StationResource::collection($result->stations),
'meta' => [
'count' => $result->stations->count(),
'fuel_type' => $criteria->fuelType->value,
'radius_km' => $criteria->radiusKm,
'lat' => $criteria->lat,
'lng' => $criteria->lng,
'lowest_pence' => $result->pricesSummary['lowest'],
'highest_pence' => $result->pricesSummary['highest'],
'cheapest_price_pence' => $result->pricesSummary['lowest'],
'avg_pence' => $result->pricesSummary['avg'],
'reliability_counts' => $result->reliabilityCounts,
],
'prediction' => $result->prediction,
]);
}
/** @return array{0: float, 1: float} */
private function resolveCoordinates(NearbyStationsRequest $request): array
{
if ($request->filled('postcode')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
@@ -33,119 +64,9 @@ class StationController extends Controller
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
}
$lat = $location->lat;
$lng = $location->lng;
} else {
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
}
$fuelType = $request->fuelType();
$radius = $request->radius();
$sort = $request->sort();
$all = Station::query()
->selectRaw(
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
+ sin(radians(?)) * sin(radians(lat))
)))) AS distance_km',
[$lat, $lng, $lat],
)
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
$join->on('stations.node_id', '=', 'spc.station_id')
->where('spc.fuel_type', '=', $fuelType->value);
})
->where('stations.temporary_closure', false)
->where('stations.permanent_closure', false)
->get();
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
$stations = $sort === 'reliable'
? $filtered
->sort(function ($a, $b) {
$weightA = PriceReliability::fromUpdatedAt(
$a->price_effective_at ? Carbon::parse($a->price_effective_at) : null
)->weight();
$weightB = PriceReliability::fromUpdatedAt(
$b->price_effective_at ? Carbon::parse($b->price_effective_at) : null
)->weight();
return $weightA <=> $weightB
?: ((int) $a->price_pence <=> (int) $b->price_pence)
?: ((float) $a->distance_km <=> (float) $b->distance_km);
})
->values()
: $filtered->sortBy(match ($sort) {
'price' => fn ($s) => (int) $s->price_pence,
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
default => fn ($s) => (float) $s->distance_km,
})->values();
$prices = $stations->pluck('price_pence');
$reliabilityCounts = $stations
->groupBy(fn ($s) => PriceReliability::fromUpdatedAt(
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
)->value)
->map->count();
Search::create([
'lat_bucket' => round($lat, 2),
'lng_bucket' => round($lng, 2),
'fuel_type' => $fuelType->value,
'results_count' => $stations->count(),
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'searched_at' => now(),
'ip_hash' => hash('sha256', $request->ip() ?? ''),
]);
return response()->json([
'data' => StationResource::collection($stations),
'meta' => [
'count' => $stations->count(),
'fuel_type' => $fuelType->value,
'radius_km' => $radius,
'lat' => $lat,
'lng' => $lng,
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'cheapest_price_pence' => $prices->min(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'reliability_counts' => [
'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0),
'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0),
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
],
],
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
]);
}
/**
* Returns the prediction payload for embedding in the search response.
* Free/guest users get a stripped teaser; users with the ai_predictions
* feature get the full multi-signal payload.
*
* @return array<string, mixed>
*/
private function predictionFor(?User $user, float $lat, float $lng): array
{
$result = $this->predictionService->predict($lat, $lng);
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
if (! $canSeeFull) {
return [
'fuel_type' => $result['fuel_type'],
'predicted_direction' => $result['predicted_direction'],
'tier_locked' => true,
];
return [$location->lat, $location->lng];
}
return $result;
return [(float) $request->input('lat'), (float) $request->input('lng')];
}
}

View File

@@ -12,8 +12,13 @@ class StationResource extends JsonResource
{
public function toArray(Request $request): array
{
$updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null;
$reliability = PriceReliability::fromUpdatedAt($updatedAt);
// The controller pre-computes _updated_at / _reliability / _classification
// per row. Falling back to fresh computation keeps the resource usable
// outside that path (e.g. tests or future callers).
$updatedAt = $this->_updated_at
?? ($this->price_effective_at ? Carbon::parse($this->price_effective_at) : null);
$reliability = $this->_reliability ?? PriceReliability::fromUpdatedAt($updatedAt);
$classification = $this->_classification ?? PriceClassification::fromUpdatedAt($updatedAt);
return [
'station_id' => $this->node_id,
@@ -32,11 +37,9 @@ class StationResource extends JsonResource
'open_today' => $this->openTodayPayload(),
'price_pence' => (int) $this->price_pence,
'price' => round((int) $this->price_pence / 100, 2),
'price_updated_at' => $this->price_effective_at
? Carbon::parse($this->price_effective_at)->toISOString()
: null,
'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value,
'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(),
'price_updated_at' => $updatedAt?->toISOString(),
'price_classification' => $classification->value,
'price_classification_label' => $classification->label(),
'reliability' => $reliability->value,
'reliability_label' => $reliability->label(),
];

View File

@@ -5,15 +5,15 @@ namespace App\Jobs;
use App\Models\NotificationLog;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Notifications\FuelPriceAlert;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Resolves allowed notification channels for a user and trigger, sends
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
*
* Actual sending is stubbed until FuelPriceAlert notification class exists.
* Resolves allowed notification channels for a user and trigger, dispatches
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
*/
final class DispatchUserNotificationJob implements ShouldQueue
{
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
// Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType);
// Step 4: send and log sent notifications
// Step 4: dispatch the multi-channel notification — Laravel fans out
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
if ($allowed !== []) {
$this->user->notify(new FuelPriceAlert(
$this->triggerType,
$this->fuelType,
$this->price,
$allowed,
));
}
// Step 5: log a sent entry per allowed channel. The notify() call
// above queues per-channel sends; per-channel HTTP outcomes are
// captured in api_logs by the channel adapters themselves.
foreach ($allowed as $channel) {
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
$this->log($channel, sent: true);
}

View File

@@ -2,16 +2,26 @@
namespace App\Jobs;
use App\Events\PricesUpdatedEvent;
use App\Services\FuelPriceService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;
/**
* Background full station refresh + price poll, dispatched from the admin
* "Trigger Full Poll" button. Mirrors the `fuel:poll --full` command but
* calls the service directly so typed exceptions surface to the queue's
* failed-job handler instead of being swallowed by Artisan output buffering.
*/
class PollFuelPricesJob implements ShouldQueue
{
use Queueable;
public function handle(): void
public function handle(FuelPriceService $service): void
{
Artisan::call('fuel:poll', ['--full' => true]);
$service->refreshStations();
$inserted = $service->pollPrices();
PricesUpdatedEvent::dispatch($inserted, true);
}
}

View File

@@ -2,16 +2,18 @@
namespace App\Jobs;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
/**
* Fan-out job for scheduled WhatsApp updates (morning / evening).
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
* Dispatches one DispatchUserNotificationJob per eligible user so each
* user's send is its own queueable unit (independent retry, no shared
* failure mode across the cohort).
*
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
{
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true)
->get()
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->pluck('name')
->all();
if (empty($eligiblePlanNames)) {
return;
}
// Users who have whatsapp preference enabled
// Candidates: users who have explicitly opted in to WhatsApp.
// Per-user tier + daily-limit + scheduled-updates checks happen via
// canSendNow('whatsapp'); that single call covers tier eligibility
// (canUseChannel) AND today's notification_log count.
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
->where('enabled', true)
->distinct()
->pluck('user_id');
User::whereIn('id', $userIds)
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
$features = PlanFeatures::for($user);
->chunkById(500, function (Collection $users) use ($triggerType): void {
foreach ($users as $user) {
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
continue;
}
// Skip if their tier isn't eligible or daily limit is hit
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
return;
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
}
if (! $features->canSendNow('whatsapp')) {
return;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
});
}
}

View File

@@ -129,6 +129,8 @@ final class HandleStripeWebhook
private function bustPlanCache(User $user): void
{
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
$tag = Cache::tags(['plans']);
$tag->forget("plan_for_user_{$user->id}");
$tag->forget("plan_cadence_for_user_{$user->id}");
}
}

View File

@@ -7,7 +7,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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
{
/** @use HasFactory<ApiLogFactory> */
@@ -19,6 +33,7 @@ class ApiLog extends Model
{
return [
'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

@@ -17,7 +17,19 @@ class Plan extends Model
'name',
'stripe_price_id_monthly',
'stripe_price_id_annual',
'features',
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
'active',
];
@@ -56,28 +68,7 @@ class Plan extends Model
}
);
if ($planId !== null) {
$plan = static::find($planId);
if ($plan !== null) {
return $plan;
}
}
// Fallback for tests / partially-seeded environments: return a free-tier stub.
return new self([
'name' => PlanTier::Free->value,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
return static::findOrFail($planId);
}
/**
@@ -86,33 +77,41 @@ class Plan extends Model
*/
public static function resolveCadenceForUser(User $user): ?string
{
if (! method_exists($user, 'subscriptions')) {
return null;
}
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
$priceId = $user->subscriptions()->active()->value('stripe_price');
return $cache->remember(
"plan_cadence_for_user_{$user->id}",
3600,
function () use ($user): ?string {
if (! method_exists($user, 'subscriptions')) {
return null;
}
if ($priceId === null) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($priceId === null) {
return null;
}
if ($plan === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
return null;
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
}
);
}
protected static function booted(): void
@@ -127,7 +126,17 @@ class Plan extends Model
protected function casts(): array
{
return [
'features' => 'array',
'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => '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

@@ -10,15 +10,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
class StationPriceArchive extends Model
{
protected $table = 'station_prices_archive';
public $timestamps = false;
protected function casts(): array
{
return [
'fuel_type' => FuelType::class,
'fuel_type' => FuelType::class,
'price_effective_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
];
}

View File

@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
public $timestamps = false;
protected $primaryKey = null;
protected $primaryKey = 'station_id';
protected $keyType = 'string';
public $incrementing = false;

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Laravel\Cashier\Subscription as CashierSubscription;
class Subscription extends CashierSubscription
{
protected $casts = [
'ends_at' => 'datetime',
'quantity' => 'integer',
'trial_ends_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'stripe_data' => 'array',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\FuelType;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
{
return [
'enabled' => 'boolean',
'fuel_type' => FuelType::class,
];
}
}

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

@@ -0,0 +1,70 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends push notifications via the OneSignal REST API.
*
* Notifications targeting this channel must implement `toOneSignal($notifiable)`
* returning ['heading' => string, 'message' => string] (or `null` to skip).
*
* No-ops when ONESIGNAL_APP_ID/API_KEY are unset, when the notifiable user has
* no `push_token`, or when toOneSignal() returns null. Each call is logged to
* api_logs through ApiLogger.
*/
final class OneSignalChannel
{
public const string NAME = 'onesignal';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$appId = config('services.onesignal.app_id');
$apiKey = config('services.onesignal.api_key');
if ($appId === null || $apiKey === null) {
Log::info('OneSignalChannel: skipped — credentials not configured');
return;
}
$playerId = $notifiable->push_token ?? null;
if ($playerId === null) {
return;
}
$payload = method_exists($notification, 'toOneSignal')
? $notification->toOneSignal($notifiable)
: null;
if ($payload === null) {
return;
}
$url = 'https://api.onesignal.com/notifications';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->withToken($apiKey)
->acceptJson()
->post($url, [
'app_id' => $appId,
'include_player_ids' => [$playerId],
'headings' => ['en' => $payload['heading'] ?? 'Fuel Alert'],
'contents' => ['en' => $payload['message'] ?? ''],
]));
} catch (Throwable $e) {
Log::error('OneSignalChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends SMS messages via the Vonage SMS API (raw HTTP no SDK).
*
* Notifications targeting this channel must implement `toVonageSms($notifiable)`
* returning a string body (or `null` to skip).
*
* No-ops when VONAGE_KEY/SECRET are unset or when the notifiable user has no
* phone number on `whatsapp_number` (the same verified column doubles as SMS
* destination).
*/
final class VonageSmsChannel
{
public const string NAME = 'vonage-sms';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$key = config('services.vonage.key');
$secret = config('services.vonage.secret');
$from = config('services.vonage.sms_from', 'FuelAlert');
if ($key === null || $secret === null) {
Log::info('VonageSmsChannel: skipped — credentials not configured');
return;
}
$to = $notifiable->whatsapp_number ?? null;
if ($to === null) {
return;
}
$body = method_exists($notification, 'toVonageSms')
? $notification->toVonageSms($notifiable)
: null;
if ($body === null) {
return;
}
$url = 'https://rest.nexmo.com/sms/json';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->asForm()
->post($url, [
'api_key' => $key,
'api_secret' => $secret,
'from' => $from,
'to' => ltrim($to, '+'),
'text' => $body,
]));
} catch (Throwable $e) {
Log::error('VonageSmsChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Notifications\Channels;
use App\Services\ApiLogger;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Sends WhatsApp messages via the Vonage Messages API (raw HTTP no SDK).
*
* Notifications targeting this channel must implement `toVonageWhatsApp($notifiable)`
* returning a string body (or `null` to skip).
*
* No-ops when VONAGE_KEY/SECRET/whatsapp_from are unset, when the user is not
* verified (no whatsapp_verified_at), when whatsapp_number is missing, or when
* the notification returns null.
*/
final class VonageWhatsAppChannel
{
public const string NAME = 'vonage-whatsapp';
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
$key = config('services.vonage.key');
$secret = config('services.vonage.secret');
$from = config('services.vonage.whatsapp_from');
if ($key === null || $secret === null || $from === null) {
Log::info('VonageWhatsAppChannel: skipped — credentials not configured');
return;
}
$to = $notifiable->whatsapp_number ?? null;
$verified = $notifiable->whatsapp_verified_at ?? null;
if ($to === null || $verified === null) {
return;
}
$body = method_exists($notification, 'toVonageWhatsApp')
? $notification->toVonageWhatsApp($notifiable)
: null;
if ($body === null) {
return;
}
$url = 'https://api.nexmo.com/v1/messages';
try {
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
->withBasicAuth($key, $secret)
->acceptJson()
->post($url, [
'message_type' => 'text',
'channel' => 'whatsapp',
'from' => $from,
'to' => $to,
'text' => $body,
]));
} catch (Throwable $e) {
Log::error('VonageWhatsAppChannel: send failed', ['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Notifications;
use App\Notifications\Channels\OneSignalChannel;
use App\Notifications\Channels\VonageSmsChannel;
use App\Notifications\Channels\VonageWhatsAppChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Multi-channel fuel price alert. The dispatching job already filters channels
* by tier, user preference, and daily limit `via()` returns exactly that
* filtered set. The notification is queued so individual channel sends don't
* block the dispatch job.
*
* Channel keys map to:
* 'email' mail (Laravel built-in)
* 'push' OneSignalChannel
* 'whatsapp' VonageWhatsAppChannel
* 'sms' VonageSmsChannel
*/
final class FuelPriceAlert extends Notification implements ShouldQueue
{
use Queueable;
/** @var array<string, class-string> */
private const array CHANNEL_MAP = [
'email' => 'mail',
'push' => OneSignalChannel::class,
'whatsapp' => VonageWhatsAppChannel::class,
'sms' => VonageSmsChannel::class,
];
/** @param string[] $channels Pre-filtered channel keys ('email', 'push', 'whatsapp', 'sms') */
public function __construct(
public readonly string $triggerType,
public readonly string $fuelType,
public readonly ?float $price,
public readonly array $channels,
) {
$this->onQueue('notifications');
}
/** @return array<int, string> */
public function via(mixed $notifiable): array
{
return array_values(array_map(
fn (string $key) => self::CHANNEL_MAP[$key] ?? $key,
$this->channels,
));
}
public function toMail(mixed $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->headline())
->greeting("Hi {$notifiable->name},")
->line($this->body())
->action('Open FuelAlert', route('dashboard'))
->line('You can change which alerts you receive in your account settings.');
}
/** @return array{heading: string, message: string} */
public function toOneSignal(mixed $notifiable): array
{
return [
'heading' => $this->headline(),
'message' => $this->body(),
];
}
public function toVonageWhatsApp(mixed $notifiable): string
{
return $this->shortBody();
}
public function toVonageSms(mixed $notifiable): string
{
return $this->shortBody();
}
private function headline(): string
{
return match ($this->triggerType) {
'price_threshold' => 'Price hit your threshold',
'score_change' => 'Fill-up signal changed',
'scheduled_morning' => 'Morning fuel update',
'scheduled_evening' => 'Evening fuel update',
default => 'Fuel alert',
};
}
private function body(): string
{
$fuel = strtoupper($this->fuelType);
$price = $this->price !== null ? number_format($this->price, 1).'p' : null;
return match ($this->triggerType) {
'price_threshold' => $price !== null
? "{$fuel} dropped to {$price} near you."
: "{$fuel} hit your alert threshold.",
'score_change' => "The {$fuel} fill-up score has changed near you.",
'scheduled_morning', 'scheduled_evening' => "Latest {$fuel} update is ready in your dashboard.",
default => "There's a new {$fuel} alert for you.",
};
}
/** SMS/WhatsApp must stay short — single line, ~160 chars max. */
private function shortBody(): string
{
return $this->headline().': '.$this->body();
}
}

View File

@@ -4,11 +4,6 @@ namespace App\Providers;
use App\Listeners\HandleStripeWebhook;
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 Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@@ -25,15 +20,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->bind(OilPredictionProvider::class, function ($app) {
$logger = $app->make(ApiLogger::class);
return match (config('services.llm.provider')) {
'openai' => new OpenAiPredictionProvider($logger),
'gemini' => new GeminiPredictionProvider($logger),
default => new AnthropicPredictionProvider($logger),
};
});
// No bindings here. The legacy LLM prediction provider binding
// was removed when the Phase 4 ridge model + Phase 8
// LlmOverlayService replaced the old daily oil prediction.
}
/**
@@ -59,13 +48,6 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(),
);
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
if (DB::connection()->getDriverName() === 'sqlite') {
$pdo = DB::connection()->getPdo();
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
}
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()

View File

@@ -3,18 +3,29 @@
namespace App\Services;
use App\Models\ApiLog;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Throwable;
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.
*
* The callable must return an Illuminate\Http\Client\Response.
* 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
*/
public function send(string $service, string $method, string $url, callable $request): Response
@@ -22,19 +33,31 @@ class ApiLogger
$start = microtime(true);
$statusCode = null;
$error = null;
$responseBody = null;
$usage = [];
try {
$response = $request();
$statusCode = $response->status();
$usage = $this->extractUsage($response);
if ($response->failed()) {
$error = Str::limit($response->body(), 1000);
$body = $response->body();
$error = Str::limit($body, 1000);
$responseBody = $this->truncate($body);
}
return $response;
} catch (Throwable $e) {
$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;
} finally {
ApiLog::create([
@@ -44,7 +67,51 @@ class ApiLogger
'status_code' => $statusCode,
'duration_ms' => (int) round((microtime(true) - $start) * 1000),
'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']);
}
/**
* 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,135 +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_ALPHA = 0.3;
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();
}
/**
* Generate EWMA + LLM predictions, store them, and flag the latest
* brent_prices row as having a prediction generated.
*/
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;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
}
$result = $llm ?? $ewma;
if ($result !== null) {
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $result;
}
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 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(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(),
]);
}
/**
* @param float[] $prices Chronological (oldest first).
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
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

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EiaBrentPriceSource
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'api_key' => config('services.eia.api_key'),
'frequency' => 'daily',
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
'sort[0][direction]' => 'desc',
'length' => 30,
]));
if (! $response->successful()) {
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
if ($rows === []) {
Log::warning('EiaBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class FredBrentPriceSource
@@ -14,45 +15,76 @@ final class FredBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
return $this->call([
'sort_order' => 'desc',
'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',
], $extraParams);
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'sort_order' => 'desc',
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
if ($rows === []) {
Log::warning('FredBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
$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) {
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

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;
}
}

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