Compare commits
34 Commits
088fd11058
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd45588e9 | ||
|
|
598ef04645 | ||
|
|
07e0789044 | ||
|
|
97e27fc057 | ||
|
|
11a3b433ff | ||
|
|
8dad223d06 | ||
|
|
1c46667f56 | ||
|
|
203200acb9 | ||
|
|
ddd591ad47 | ||
|
|
d13a29df01 | ||
|
|
c2c237a1b3 | ||
|
|
25cf022964 | ||
|
|
e821a934a5 | ||
|
|
73de53994f | ||
|
|
df70e514e9 | ||
|
|
28061541d4 | ||
|
|
895d55439b | ||
|
|
aff6dd1e0f | ||
|
|
06f5f2035f | ||
|
|
69eb524e07 | ||
|
|
b4ef1177b2 | ||
|
|
8e29980dfe | ||
|
|
4ce5066596 | ||
|
|
c46b017b51 | ||
|
|
7f64c42a23 | ||
|
|
4d9df1ee19 | ||
|
|
5369b4a5a0 | ||
|
|
27c82ef103 | ||
|
|
e39618f5df | ||
|
|
00d0f7c8ec | ||
|
|
48af2083f3 | ||
|
|
783297694c | ||
|
|
775e076bb7 | ||
|
|
8695d5ec95 |
BIN
.claude/.DS_Store
vendored
BIN
.claude/.DS_Store
vendored
Binary file not shown.
211
.claude/rules/prediction.md
Normal file
211
.claude/rules/prediction.md
Normal 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 (0–100) | 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 |
|
||||
| 40–69 | `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
30
.claude/settings.json
Normal 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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
48
.env.example
48
.env.example
@@ -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
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
app/Console/Commands/BackfillOilPrices.php
Normal file
33
app/Console/Commands/BackfillOilPrices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/RunLlmOverlay.php
Normal file
34
app/Console/Commands/RunLlmOverlay.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 Apr–May 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
45
app/Models/Backtest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Models/ForecastOutcome.php
Normal file
36
app/Models/ForecastOutcome.php
Normal 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
35
app/Models/LlmOverlay.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
protected $primaryKey = 'station_id';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
|
||||
17
app/Models/Subscription.php
Normal file
17
app/Models/Subscription.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Models/VolatilityRegime.php
Normal file
30
app/Models/VolatilityRegime.php
Normal 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();
|
||||
}
|
||||
}
|
||||
28
app/Models/WatchedEvent.php
Normal file
28
app/Models/WatchedEvent.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Models/WeeklyForecast.php
Normal file
35
app/Models/WeeklyForecast.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Notifications/Channels/OneSignalChannel.php
Normal file
70
app/Notifications/Channels/OneSignalChannel.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Notifications/Channels/VonageSmsChannel.php
Normal file
71
app/Notifications/Channels/VonageSmsChannel.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal file
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/Notifications/FuelPriceAlert.php
Normal file
116
app/Notifications/FuelPriceAlert.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Services/Forecasting/AccuracyHistory.php
Normal file
36
app/Services/Forecasting/AccuracyHistory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
162
app/Services/Forecasting/BacktestRunner.php
Normal file
162
app/Services/Forecasting/BacktestRunner.php
Normal 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+',
|
||||
};
|
||||
}
|
||||
}
|
||||
138
app/Services/Forecasting/BeisImporter.php
Normal file
138
app/Services/Forecasting/BeisImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal file
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal 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;
|
||||
}
|
||||
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal file
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal 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;
|
||||
}
|
||||
45
app/Services/Forecasting/DutyChangeDetector.php
Normal file
45
app/Services/Forecasting/DutyChangeDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Services/Forecasting/FeatureSpec.php
Normal file
54
app/Services/Forecasting/FeatureSpec.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Services/Forecasting/Features/DeltaUlsdLag.php
Normal file
50
app/Services/Forecasting/Features/DeltaUlsdLag.php
Normal 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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Services/Forecasting/Features/DeltaUlspLag.php
Normal file
57
app/Services/Forecasting/Features/DeltaUlspLag.php
Normal 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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Services/Forecasting/Features/IsPreBankHoliday.php
Normal file
32
app/Services/Forecasting/Features/IsPreBankHoliday.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
57
app/Services/Forecasting/Features/UlspMinusMa8.php
Normal file
57
app/Services/Forecasting/Features/UlspMinusMa8.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
app/Services/Forecasting/Features/WeekOfYearTrig.php
Normal file
43
app/Services/Forecasting/Features/WeekOfYearTrig.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
41
app/Services/Forecasting/LeakDetector.php
Normal file
41
app/Services/Forecasting/LeakDetector.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Services/Forecasting/LeakDetectorException.php
Normal file
19
app/Services/Forecasting/LeakDetectorException.php
Normal 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));
|
||||
}
|
||||
}
|
||||
20
app/Services/Forecasting/LeakReport.php
Normal file
20
app/Services/Forecasting/LeakReport.php
Normal 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 !== [];
|
||||
}
|
||||
}
|
||||
200
app/Services/Forecasting/LinearAlgebra.php
Normal file
200
app/Services/Forecasting/LinearAlgebra.php
Normal 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); Gauss–Jordan 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 Gauss–Jordan 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);
|
||||
}
|
||||
}
|
||||
658
app/Services/Forecasting/LlmOverlayService.php
Normal file
658
app/Services/Forecasting/LlmOverlayService.php
Normal 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' => '1–2 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;
|
||||
}
|
||||
}
|
||||
147
app/Services/Forecasting/LocalSnapshotService.php
Normal file
147
app/Services/Forecasting/LocalSnapshotService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
39
app/Services/Forecasting/Models/NaiveZeroChangeModel.php
Normal file
39
app/Services/Forecasting/Models/NaiveZeroChangeModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
201
app/Services/Forecasting/Models/RidgeRegressionModel.php
Normal file
201
app/Services/Forecasting/Models/RidgeRegressionModel.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
87
app/Services/Forecasting/OutcomeResolver.php
Normal file
87
app/Services/Forecasting/OutcomeResolver.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
103
app/Services/Forecasting/ReasoningGenerator.php
Normal file
103
app/Services/Forecasting/ReasoningGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
146
app/Services/Forecasting/UkBankHolidays.php
Normal file
146
app/Services/Forecasting/UkBankHolidays.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
209
app/Services/Forecasting/VolatilityRegimeService.php
Normal file
209
app/Services/Forecasting/VolatilityRegimeService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
307
app/Services/Forecasting/WeeklyForecastService.php
Normal file
307
app/Services/Forecasting/WeeklyForecastService.php
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
20
app/Services/Forecasting/WeeklyPrediction.php
Normal file
20
app/Services/Forecasting/WeeklyPrediction.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
58
app/Services/Forecasting/WeeklyPumpPriceLoader.php
Normal file
58
app/Services/Forecasting/WeeklyPumpPriceLoader.php
Normal 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
Reference in New Issue
Block a user