Compare commits
17 Commits
775e076bb7
...
28061541d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28061541d4 | ||
|
|
895d55439b | ||
|
|
aff6dd1e0f | ||
|
|
06f5f2035f | ||
|
|
69eb524e07 | ||
|
|
b4ef1177b2 | ||
|
|
8e29980dfe | ||
|
|
4ce5066596 | ||
|
|
c46b017b51 | ||
|
|
7f64c42a23 | ||
|
|
4d9df1ee19 | ||
|
|
5369b4a5a0 | ||
|
|
27c82ef103 | ||
|
|
e39618f5df | ||
|
|
00d0f7c8ec | ||
|
|
48af2083f3 | ||
|
|
783297694c |
@@ -72,6 +72,14 @@ ANTHROPIC_API_KEY=
|
|||||||
ANTHROPIC_MODEL=claude-haiku-4-5
|
ANTHROPIC_MODEL=claude-haiku-4-5
|
||||||
|
|
||||||
FRED_API_KEY=
|
FRED_API_KEY=
|
||||||
|
|
||||||
|
ONESIGNAL_APP_ID=
|
||||||
|
ONESIGNAL_API_KEY=
|
||||||
|
|
||||||
|
VONAGE_KEY=
|
||||||
|
VONAGE_SECRET=
|
||||||
|
VONAGE_WHATSAPP_FROM=
|
||||||
|
VONAGE_SMS_FROM=FuelAlert
|
||||||
API_SECRET_KEY=
|
API_SECRET_KEY=
|
||||||
EIA_API_KEY=
|
EIA_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use App\Concerns\ProfileValidationRules;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
use PasswordValidationRules, ProfileValidationRules;
|
use PasswordValidationRules;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
...$this->profileRules(),
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,4 @@ trait PasswordValidationRules
|
|||||||
{
|
{
|
||||||
return ['required', 'string', Password::default(), 'confirmed'];
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
#[Signature('oil:fetch')]
|
#[Signature('oil:fetch')]
|
||||||
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
||||||
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
|
|||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (BrentPriceFetchException $e) {
|
} 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...');
|
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
|
|||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (BrentPriceFetchException $e) {
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
|
||||||
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ namespace App\Console\Commands;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||||
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||||
@@ -79,12 +82,21 @@ final class ImportPostcodes extends Command
|
|||||||
|
|
||||||
$hasDoterm = isset($columns['doterm']);
|
$hasDoterm = isset($columns['doterm']);
|
||||||
|
|
||||||
DB::table('postcodes')->truncate();
|
// Stream into a staging table first. Only swap into the live
|
||||||
DB::table('outcodes')->truncate();
|
// 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 = [];
|
$buffer = [];
|
||||||
$imported = 0;
|
$imported = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
while (($row = fgetcsv($handle)) !== false) {
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||||
continue;
|
continue;
|
||||||
@@ -111,23 +123,36 @@ final class ImportPostcodes extends Command
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (count($buffer) >= self::CHUNK_SIZE) {
|
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||||
DB::table('postcodes')->insert($buffer);
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
$imported += count($buffer);
|
$imported += count($buffer);
|
||||||
$buffer = [];
|
$buffer = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($buffer !== []) {
|
if ($buffer !== []) {
|
||||||
DB::table('postcodes')->insert($buffer);
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
$imported += count($buffer);
|
$imported += count($buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
// 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(
|
DB::statement(
|
||||||
'INSERT INTO outcodes (outcode, lat, lng)
|
'INSERT INTO outcodes (outcode, lat, lng)
|
||||||
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
$this->info("Imported {$imported} postcodes.");
|
$this->info("Imported {$imported} postcodes.");
|
||||||
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\UserResource\Pages\EditUser;
|
|||||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
@@ -75,7 +76,7 @@ class UserResource extends Resource
|
|||||||
->live()
|
->live()
|
||||||
->dehydrated(false)
|
->dehydrated(false)
|
||||||
->afterStateHydrated(fn (Select $component, ?User $record) => $component
|
->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')
|
Select::make('cadence')
|
||||||
->label('Billing Cadence')
|
->label('Billing Cadence')
|
||||||
->options([
|
->options([
|
||||||
@@ -131,7 +132,7 @@ class UserResource extends Resource
|
|||||||
TextColumn::make('postcode')->placeholder('—'),
|
TextColumn::make('postcode')->placeholder('—'),
|
||||||
TextColumn::make('tier')
|
TextColumn::make('tier')
|
||||||
->label('Tier')
|
->label('Tier')
|
||||||
->state(fn (User $record): string => Plan::resolveForUser($record)->name)
|
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
|
||||||
->badge()
|
->badge()
|
||||||
->colors([
|
->colors([
|
||||||
'gray' => 'free',
|
'gray' => 'free',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -70,7 +71,7 @@ class AuthController extends Controller
|
|||||||
return response()->json(array_merge(
|
return response()->json(array_merge(
|
||||||
$user->toArray(),
|
$user->toArray(),
|
||||||
[
|
[
|
||||||
'tier' => Plan::resolveForUser($user)->name,
|
'tier' => PlanFeatures::for($user)->tier(),
|
||||||
'subscription_cancelled' => $subscription?->canceled() ?? false,
|
'subscription_cancelled' => $subscription?->canceled() ?? false,
|
||||||
'subscription_cadence' => Plan::resolveCadenceForUser($user),
|
'subscription_cadence' => Plan::resolveCadenceForUser($user),
|
||||||
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
|
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
|
||||||
|
|||||||
@@ -2,29 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Enums\PriceReliability;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\NearbyStationsRequest;
|
use App\Http\Requests\Api\NearbyStationsRequest;
|
||||||
use App\Http\Resources\Api\StationResource;
|
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 App\Services\PostcodeService;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use App\Services\StationSearch\SearchCriteria;
|
||||||
|
use App\Services\StationSearch\StationSearchService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StationController extends Controller
|
class StationController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PostcodeService $postcodeService,
|
private readonly PostcodeService $postcodeService,
|
||||||
private readonly NationalFuelPredictionService $predictionService,
|
private readonly StationSearchService $searchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(NearbyStationsRequest $request): JsonResponse
|
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')) {
|
if ($request->filled('postcode')) {
|
||||||
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
||||||
@@ -33,119 +64,9 @@ class StationController extends Controller
|
|||||||
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$lat = $location->lat;
|
return [$location->lat, $location->lng];
|
||||||
$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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return [(float) $request->input('lat'), (float) $request->input('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 $result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ class StationResource extends JsonResource
|
|||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null;
|
// The controller pre-computes _updated_at / _reliability / _classification
|
||||||
$reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
// 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 [
|
return [
|
||||||
'station_id' => $this->node_id,
|
'station_id' => $this->node_id,
|
||||||
@@ -32,11 +37,9 @@ class StationResource extends JsonResource
|
|||||||
'open_today' => $this->openTodayPayload(),
|
'open_today' => $this->openTodayPayload(),
|
||||||
'price_pence' => (int) $this->price_pence,
|
'price_pence' => (int) $this->price_pence,
|
||||||
'price' => round((int) $this->price_pence / 100, 2),
|
'price' => round((int) $this->price_pence / 100, 2),
|
||||||
'price_updated_at' => $this->price_effective_at
|
'price_updated_at' => $updatedAt?->toISOString(),
|
||||||
? Carbon::parse($this->price_effective_at)->toISOString()
|
'price_classification' => $classification->value,
|
||||||
: null,
|
'price_classification_label' => $classification->label(),
|
||||||
'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value,
|
|
||||||
'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(),
|
|
||||||
'reliability' => $reliability->value,
|
'reliability' => $reliability->value,
|
||||||
'reliability_label' => $reliability->label(),
|
'reliability_label' => $reliability->label(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ namespace App\Jobs;
|
|||||||
use App\Models\NotificationLog;
|
use App\Models\NotificationLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserNotificationPreference;
|
use App\Models\UserNotificationPreference;
|
||||||
|
use App\Notifications\FuelPriceAlert;
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves allowed notification channels for a user and trigger, sends
|
* Resolves allowed notification channels for a user and trigger, dispatches
|
||||||
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
|
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
|
||||||
*
|
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
|
||||||
* Actual sending is stubbed until FuelPriceAlert notification class exists.
|
|
||||||
*/
|
*/
|
||||||
final class DispatchUserNotificationJob implements ShouldQueue
|
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
|
// Step 3: channels that pass tier + user-pref + daily-limit checks
|
||||||
$allowed = $features->channelsFor($this->triggerType);
|
$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) {
|
foreach ($allowed as $channel) {
|
||||||
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
|
|
||||||
$this->log($channel, sent: true);
|
$this->log($channel, sent: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,26 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\PricesUpdatedEvent;
|
||||||
|
use App\Services\FuelPriceService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
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
|
class PollFuelPricesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Plan;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserNotificationPreference;
|
use App\Models\UserNotificationPreference;
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fan-out job for scheduled WhatsApp updates (morning / evening).
|
* 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.
|
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
|
||||||
*/
|
*/
|
||||||
@@ -28,36 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
||||||
|
|
||||||
// Plans that allow scheduled WhatsApp updates
|
// Candidates: users who have explicitly opted in to WhatsApp.
|
||||||
$eligiblePlanNames = Plan::where('active', true)
|
// Per-user tier + daily-limit + scheduled-updates checks happen via
|
||||||
->where('whatsapp_scheduled_updates', '>', 0)
|
// canSendNow('whatsapp'); that single call covers tier eligibility
|
||||||
->pluck('name')
|
// (canUseChannel) AND today's notification_log count.
|
||||||
->all();
|
|
||||||
|
|
||||||
if (empty($eligiblePlanNames)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users who have whatsapp preference enabled
|
|
||||||
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
||||||
->where('enabled', true)
|
->where('enabled', true)
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('user_id');
|
->pluck('user_id');
|
||||||
|
|
||||||
User::whereIn('id', $userIds)
|
User::whereIn('id', $userIds)
|
||||||
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
|
->chunkById(500, function (Collection $users) use ($triggerType): void {
|
||||||
$features = PlanFeatures::for($user);
|
foreach ($users as $user) {
|
||||||
|
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
|
||||||
// Skip if their tier isn't eligible or daily limit is hit
|
continue;
|
||||||
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $features->canSendNow('whatsapp')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ final class HandleStripeWebhook
|
|||||||
|
|
||||||
private function bustPlanCache(User $user): void
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ class Plan extends Model
|
|||||||
*/
|
*/
|
||||||
public static function resolveCadenceForUser(User $user): ?string
|
public static function resolveCadenceForUser(User $user): ?string
|
||||||
{
|
{
|
||||||
|
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||||
|
|
||||||
|
return $cache->remember(
|
||||||
|
"plan_cadence_for_user_{$user->id}",
|
||||||
|
3600,
|
||||||
|
function () use ($user): ?string {
|
||||||
if (! method_exists($user, 'subscriptions')) {
|
if (! method_exists($user, 'subscriptions')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,8 @@ class Plan extends Model
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||||
class StationPriceArchive extends Model
|
class StationPriceArchive extends Model
|
||||||
{
|
{
|
||||||
|
protected $table = 'station_prices_archive';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
|
|||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $primaryKey = null;
|
protected $primaryKey = 'station_id';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
use Database\Factories\UserNotificationPreferenceFactory;
|
use Database\Factories\UserNotificationPreferenceFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'enabled' => 'boolean',
|
'enabled' => 'boolean',
|
||||||
|
'fuel_type' => FuelType::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,13 +59,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
app()->isProduction(),
|
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::defaults(fn (): ?Password => app()->isProduction()
|
||||||
? Password::min(12)
|
? Password::min(12)
|
||||||
->mixedCase()
|
->mixedCase()
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
final class BrentPricePredictor
|
final class BrentPricePredictor
|
||||||
{
|
{
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||||||
|
|
||||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
private const int EWMA_MAX_CONFIDENCE = 65;
|
||||||
@@ -33,8 +31,10 @@ final class BrentPricePredictor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate EWMA + LLM predictions, store them, and flag the latest
|
* Try LLM first; persist EWMA only as a fallback when the LLM provider
|
||||||
* brent_prices row as having a prediction generated.
|
* returns null. The downstream OilSignal already prefers LLM
|
||||||
|
* (llm_with_context > llm > ewma), so writing both rows on every run is
|
||||||
|
* dead weight 95% of the time. EWMA still acts as the safety net.
|
||||||
*/
|
*/
|
||||||
public function generatePrediction(): ?PricePrediction
|
public function generatePrediction(): ?PricePrediction
|
||||||
{
|
{
|
||||||
@@ -48,25 +48,23 @@ final class BrentPricePredictor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ewma = $this->generateEwmaPrediction($prices);
|
|
||||||
|
|
||||||
if ($ewma !== null) {
|
|
||||||
PricePrediction::create($ewma->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
$llm = $this->provider->predict($prices);
|
$llm = $this->provider->predict($prices);
|
||||||
|
|
||||||
if ($llm !== null) {
|
if ($llm !== null) {
|
||||||
PricePrediction::create($llm->toArray());
|
PricePrediction::create($llm->toArray());
|
||||||
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||||
|
|
||||||
|
return $llm;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $llm ?? $ewma;
|
$ewma = $this->generateEwmaPrediction($prices);
|
||||||
|
|
||||||
if ($result !== null) {
|
if ($ewma !== null) {
|
||||||
|
PricePrediction::create($ewma->toArray());
|
||||||
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $ewma;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
||||||
@@ -77,8 +75,8 @@ final class BrentPricePredictor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
$ewma3 = Ewma::compute(array_slice($chronological, -3));
|
||||||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
$ewma7 = Ewma::compute(array_slice($chronological, -7));
|
||||||
|
|
||||||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
||||||
|
|
||||||
@@ -112,20 +110,6 @@ final class BrentPricePredictor
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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
|
private function ewmaConfidence(float $changePct): int
|
||||||
{
|
{
|
||||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||||
|
|||||||
25
app/Services/Ewma.php
Normal file
25
app/Services/Ewma.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponentially-weighted moving average. Pure function — used by
|
||||||
|
* BrentPricePredictor for the EWMA fallback prediction and by
|
||||||
|
* AnthropicPredictionProvider to enrich the basic-flow prompt.
|
||||||
|
*/
|
||||||
|
final class Ewma
|
||||||
|
{
|
||||||
|
public const float DEFAULT_ALPHA = 0.3;
|
||||||
|
|
||||||
|
/** @param float[] $prices Chronological order (oldest first). */
|
||||||
|
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
|
||||||
|
{
|
||||||
|
$ema = $prices[0];
|
||||||
|
|
||||||
|
foreach (array_slice($prices, 1) as $price) {
|
||||||
|
$ema = $alpha * $price + (1 - $alpha) * $ema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($ema, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,57 +67,15 @@ class FuelPriceService
|
|||||||
*/
|
*/
|
||||||
public function pollPrices(): int
|
public function pollPrices(): int
|
||||||
{
|
{
|
||||||
$token = $this->getAccessToken();
|
|
||||||
$inserted = 0;
|
|
||||||
$batch = 1;
|
|
||||||
$pollStartedAt = now();
|
$pollStartedAt = now();
|
||||||
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
|
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
|
||||||
$completedCleanly = false;
|
$sinceCarbon = $since instanceof CarbonInterface ? $since : null;
|
||||||
|
|
||||||
do {
|
[$inserted, $completedCleanly] = $this->iterateBatches(
|
||||||
try {
|
'/pfs/fuel-prices',
|
||||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
|
$sinceCarbon,
|
||||||
$params = ['batch-number' => $batch];
|
fn (array $stations): int => $this->processPriceBatch($stations),
|
||||||
|
);
|
||||||
if ($since instanceof CarbonInterface) {
|
|
||||||
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
|
||||||
}
|
|
||||||
|
|
||||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
|
||||||
->withToken($token)
|
|
||||||
->get($baseUrl, $params));
|
|
||||||
|
|
||||||
if ($response->notFound()) {
|
|
||||||
$completedCleanly = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('FuelPriceService: price batch returned error', [
|
|
||||||
'batch' => $batch,
|
|
||||||
'status' => $response->status(),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stations = $response->json() ?? [];
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('FuelPriceService: price batch fetch failed', [
|
|
||||||
'batch' => $batch,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($stations)) {
|
|
||||||
$completedCleanly = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inserted += $this->processPriceBatch($stations);
|
|
||||||
$batch++;
|
|
||||||
} while (true);
|
|
||||||
|
|
||||||
if ($completedCleanly) {
|
if ($completedCleanly) {
|
||||||
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
|
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
|
||||||
@@ -131,25 +89,53 @@ class FuelPriceService
|
|||||||
* Called on full daily refresh before pollPrices().
|
* Called on full daily refresh before pollPrices().
|
||||||
*/
|
*/
|
||||||
public function refreshStations(): void
|
public function refreshStations(): void
|
||||||
|
{
|
||||||
|
$this->iterateBatches('/pfs', null, function (array $stations): int {
|
||||||
|
$this->upsertStations($stations);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive a paginated fuel-finder endpoint until exhausted, calling
|
||||||
|
* $process on each non-empty batch. Returns the sum of $process return
|
||||||
|
* values plus a flag indicating the loop exited cleanly (404 or empty
|
||||||
|
* body) rather than via an HTTP error or thrown exception. Callers use
|
||||||
|
* the flag to decide whether to update incremental-poll bookkeeping.
|
||||||
|
*
|
||||||
|
* @param callable(array<int, array<string, mixed>>): int $process
|
||||||
|
* @return array{0: int, 1: bool}
|
||||||
|
*/
|
||||||
|
private function iterateBatches(string $endpoint, ?CarbonInterface $since, callable $process): array
|
||||||
{
|
{
|
||||||
$token = $this->getAccessToken();
|
$token = $this->getAccessToken();
|
||||||
|
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
|
||||||
|
$total = 0;
|
||||||
$batch = 1;
|
$batch = 1;
|
||||||
|
$completedCleanly = false;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try {
|
try {
|
||||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
|
|
||||||
$params = ['batch-number' => $batch];
|
$params = ['batch-number' => $batch];
|
||||||
|
|
||||||
|
if ($since !== null) {
|
||||||
|
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||||||
->withToken($token)
|
->withToken($token)
|
||||||
->get($baseUrl, $params));
|
->get($baseUrl, $params));
|
||||||
|
|
||||||
if ($response->notFound()) {
|
if ($response->notFound()) {
|
||||||
break; // No more batches
|
$completedCleanly = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('FuelPriceService: station batch returned error', [
|
Log::error('FuelPriceService: batch returned error', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
'batch' => $batch,
|
'batch' => $batch,
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
]);
|
]);
|
||||||
@@ -158,7 +144,8 @@ class FuelPriceService
|
|||||||
|
|
||||||
$stations = $response->json() ?? [];
|
$stations = $response->json() ?? [];
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('FuelPriceService: station batch fetch failed', [
|
Log::error('FuelPriceService: batch fetch failed', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
'batch' => $batch,
|
'batch' => $batch,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
@@ -166,12 +153,15 @@ class FuelPriceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (empty($stations)) {
|
if (empty($stations)) {
|
||||||
|
$completedCleanly = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->upsertStations($stations);
|
$total += $process($stations);
|
||||||
$batch++;
|
$batch++;
|
||||||
} while (true);
|
} while (true);
|
||||||
|
|
||||||
|
return [$total, $completedCleanly];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<int, array<string, mixed>> $apiStations */
|
/** @param array<int, array<string, mixed>> $apiStations */
|
||||||
@@ -209,9 +199,9 @@ class FuelPriceService
|
|||||||
'postcode' => $data['location']['postcode'],
|
'postcode' => $data['location']['postcode'],
|
||||||
'lat' => $data['location']['latitude'],
|
'lat' => $data['location']['latitude'],
|
||||||
'lng' => $data['location']['longitude'],
|
'lng' => $data['location']['longitude'],
|
||||||
'amenities' => self::flattenEnabledFlags($data['amenities'] ?? []),
|
'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
|
||||||
'opening_times' => $data['opening_times'] ?? null,
|
'opening_times' => $data['opening_times'] ?? null,
|
||||||
'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []),
|
'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
|
||||||
'last_seen_at' => $now,
|
'last_seen_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -242,7 +232,7 @@ class FuelPriceService
|
|||||||
* @param array<string, bool>|array<int, string> $flags
|
* @param array<string, bool>|array<int, string> $flags
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private static function flattenEnabledFlags(array $flags): array
|
private function flattenEnabledFlags(array $flags): array
|
||||||
{
|
{
|
||||||
if ($flags === []) {
|
if ($flags === []) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
41
app/Services/HaversineQuery.php
Normal file
41
app/Services/HaversineQuery.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds canonical haversine SQL fragments for distance and within-radius
|
||||||
|
* filtering. Centralises the float-clamping (GREATEST/LEAST) and the column
|
||||||
|
* naming convention used across prediction and station search queries.
|
||||||
|
*
|
||||||
|
* Assumes the joined/queried table exposes columns `lat` and `lng`.
|
||||||
|
*/
|
||||||
|
final class HaversineQuery
|
||||||
|
{
|
||||||
|
private const string DISTANCE_KM_SQL =
|
||||||
|
'(6371 * acos(GREATEST(-1.0, LEAST(1.0, '
|
||||||
|
.'cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) '
|
||||||
|
.'+ sin(radians(?)) * sin(radians(lat))))))';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare distance-in-km expression. Caller adds aliasing or comparison.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: array{float, float, float}}
|
||||||
|
*/
|
||||||
|
public static function distanceKm(float $lat, float $lng): array
|
||||||
|
{
|
||||||
|
return [self::DISTANCE_KM_SQL, [$lat, $lng, $lat]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<= {km}` predicate suitable for whereRaw. The radius is embedded as a
|
||||||
|
* numeric literal because PDO + SQLite's whereRaw binds floats as strings
|
||||||
|
* by default, which breaks numeric comparison against the haversine
|
||||||
|
* expression. The `float` parameter is type-checked and not user input.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: array{float, float, float}}
|
||||||
|
*/
|
||||||
|
public static function withinKm(float $lat, float $lng, float $km): array
|
||||||
|
{
|
||||||
|
return [self::DISTANCE_KM_SQL.' <= '.sprintf('%F', $km), [$lat, $lng, $lat]];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
|
use App\Enums\PredictionSource;
|
||||||
|
use App\Enums\TrendDirection;
|
||||||
|
use App\Models\BrentPrice;
|
||||||
|
use App\Models\PricePrediction;
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
|
||||||
|
{
|
||||||
|
protected const int LLM_MAX_CONFIDENCE = 85;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected readonly ApiLogger $apiLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default flow: gate on API key, call the provider, normalise the payload
|
||||||
|
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
|
||||||
|
* web-search) override `predict()` directly and reuse the helper methods.
|
||||||
|
*/
|
||||||
|
public function predict(Collection $prices): ?PricePrediction
|
||||||
|
{
|
||||||
|
$apiKey = $this->apiKey();
|
||||||
|
|
||||||
|
if ($apiKey === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
|
||||||
|
|
||||||
|
return $payload === null ? null : $this->buildPrediction($payload);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the configured API key or null if not set. */
|
||||||
|
abstract protected function apiKey(): ?string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the provider HTTP call and return the normalised payload, or null
|
||||||
|
* on failure (already logged by the implementer).
|
||||||
|
*
|
||||||
|
* @return array{direction: string, confidence: int, reasoning: string}|null
|
||||||
|
*/
|
||||||
|
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
|
||||||
|
|
||||||
|
/** @param Collection<int, BrentPrice> $prices */
|
||||||
|
protected function buildPriceList(Collection $prices): string
|
||||||
|
{
|
||||||
|
return $prices->sortBy('date')
|
||||||
|
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||||
|
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
|
||||||
|
{
|
||||||
|
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||||
|
|
||||||
|
if ($direction === null) {
|
||||||
|
Log::error(static::class.': invalid direction', ['input' => $input]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PricePrediction([
|
||||||
|
'predicted_for' => now()->toDateString(),
|
||||||
|
'source' => $source,
|
||||||
|
'direction' => $direction,
|
||||||
|
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
|
||||||
|
'reasoning' => $input['reasoning'] ?? '',
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defaultPrompt(string $priceList): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||||
|
Predict the short-term direction over the next 3–5 days.
|
||||||
|
|
||||||
|
Recent Brent crude prices (USD/barrel):
|
||||||
|
{$priceList}
|
||||||
|
|
||||||
|
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||||
|
and a one-sentence reasoning.
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,31 +3,23 @@
|
|||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
use App\Enums\PredictionSource;
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
use App\Models\PricePrediction;
|
||||||
use App\Services\ApiLogger;
|
use App\Services\Ewma;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class AnthropicPredictionProvider implements OilPredictionProvider
|
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
|
||||||
|
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||||||
|
* Overrides the parent flow because Anthropic uses two phases (web search
|
||||||
|
* loop + forced tool call) and selects the source dynamically.
|
||||||
*/
|
*/
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
public function predict(Collection $prices): ?PricePrediction
|
||||||
{
|
{
|
||||||
if (! config('services.anthropic.api_key')) {
|
if ($this->apiKey() === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
return $prediction ?? $this->predictBasic($prices);
|
return $prediction ?? $this->predictBasic($prices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function apiKey(): ?string
|
||||||
|
{
|
||||||
|
return config('services.anthropic.api_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-turn web search phase, then a forced submit_prediction call.
|
* Multi-turn web search phase, then a forced submit_prediction call.
|
||||||
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
|
* Phase 1: let the model search for recent oil/geopolitical news.
|
||||||
* Phase 2: Force submit_prediction with the full conversation context.
|
* Phase 2: force submit_prediction with the full conversation context.
|
||||||
*/
|
*/
|
||||||
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
||||||
{
|
{
|
||||||
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
$url = 'https://api.anthropic.com/v1/messages';
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: web search loop
|
|
||||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||||
->withHeaders($this->headers())
|
->withHeaders($this->headers())
|
||||||
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
|
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: forced submit with full context
|
|
||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
||||||
|
|
||||||
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $submitResponse->successful()) {
|
if (! $submitResponse->successful()) {
|
||||||
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]);
|
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||||
|
|
||||||
if ($input === null) {
|
return $input === null
|
||||||
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
|
? null
|
||||||
|
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-turn prediction using a forced submit_prediction tool call.
|
||||||
|
* Guarantees structured output — no JSON parsing needed.
|
||||||
|
*/
|
||||||
|
private function predictBasic(Collection $prices): ?PricePrediction
|
||||||
|
{
|
||||||
|
$chronological = $prices->sortBy('date');
|
||||||
|
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||||
|
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||||
|
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
|
||||||
|
|
||||||
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post($url, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||||
|
'max_tokens' => 256,
|
||||||
|
'tools' => [$this->submitPredictionTool()],
|
||||||
|
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||||
|
'messages' => [[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||||
|
|
||||||
|
return $input === null ? null : $this->buildPrediction($input);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
|
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
PROMPT;
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildPriceList(Collection $prices): string
|
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
||||||
{
|
{
|
||||||
return $prices->sortBy('date')
|
return <<<PROMPT
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||||
->implode("\n");
|
Predict the short-term direction over the next 3–5 days.
|
||||||
|
|
||||||
|
Recent Brent crude prices (USD/barrel):
|
||||||
|
{$priceList}
|
||||||
|
|
||||||
|
Pre-computed indicators:
|
||||||
|
- 3-day EWMA: \${$ewma3}
|
||||||
|
- 7-day EWMA: \${$ewma7}
|
||||||
|
- 14-day EWMA: \${$ewma14}
|
||||||
|
|
||||||
|
Use the submit_prediction tool to submit your answer.
|
||||||
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private function headers(): array
|
private function headers(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'x-api-key' => config('services.anthropic.api_key'),
|
'x-api-key' => $this->apiKey(),
|
||||||
'anthropic-version' => '2023-06-01',
|
'anthropic-version' => '2023-06-01',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
|
|
||||||
return $block['input'] ?? null;
|
return $block['input'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
|
||||||
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
|
|
||||||
{
|
|
||||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => $source,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $input['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-turn prediction using a forced submit_prediction tool call.
|
|
||||||
* Guarantees structured output — no JSON parsing needed.
|
|
||||||
*/
|
|
||||||
private function predictBasic(Collection $prices): ?PricePrediction
|
|
||||||
{
|
|
||||||
$chronological = $prices->sortBy('date');
|
|
||||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
|
||||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
|
||||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
|
||||||
|
|
||||||
$priceList = $this->buildPriceList($prices);
|
|
||||||
|
|
||||||
$url = 'https://api.anthropic.com/v1/messages';
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
|
||||||
->withHeaders($this->headers())
|
|
||||||
->post($url, [
|
|
||||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
|
||||||
'max_tokens' => 256,
|
|
||||||
'tools' => [$this->submitPredictionTool()],
|
|
||||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
|
||||||
'messages' => [[
|
|
||||||
'role' => 'user',
|
|
||||||
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
|
|
||||||
]],
|
|
||||||
]));
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
|
||||||
|
|
||||||
if ($input === null) {
|
|
||||||
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildPrediction($input, PredictionSource::Llm);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param float[] $prices Chronological order (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 basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
|
||||||
{
|
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
|
||||||
{$priceList}
|
|
||||||
|
|
||||||
Pre-computed indicators:
|
|
||||||
- 3-day EWMA: \${$ewma3}
|
|
||||||
- 7-day EWMA: \${$ewma7}
|
|
||||||
- 14-day EWMA: \${$ewma14}
|
|
||||||
|
|
||||||
Use the submit_prediction tool to submit your answer.
|
|
||||||
PROMPT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,43 +2,26 @@
|
|||||||
|
|
||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Services\ApiLogger;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GeminiPredictionProvider implements OilPredictionProvider
|
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
protected function apiKey(): ?string
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
|
||||||
{
|
{
|
||||||
if (! config('services.gemini.api_key')) {
|
return config('services.gemini.api_key');
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$priceList = $prices->sortBy('date')
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
{
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
||||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
||||||
->withQueryParameters(['key' => config('services.gemini.api_key')])
|
->withQueryParameters(['key' => $apiKey])
|
||||||
->post($url, [
|
->post($url, [
|
||||||
'contents' => [[
|
'contents' => [[
|
||||||
'parts' => [['text' => $this->prompt($priceList)]],
|
'parts' => [['text' => $this->defaultPrompt($priceList)]],
|
||||||
]],
|
]],
|
||||||
'generationConfig' => [
|
'generationConfig' => [
|
||||||
'responseMimeType' => 'application/json',
|
'responseMimeType' => 'application/json',
|
||||||
@@ -58,7 +41,7 @@ class GeminiPredictionProvider implements OilPredictionProvider
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
|
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -67,45 +50,11 @@ class GeminiPredictionProvider implements OilPredictionProvider
|
|||||||
$data = json_decode($text, true);
|
$data = json_decode($text, true);
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
|
Log::error(self::class.': unexpected response format', ['text' => $text]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$direction = TrendDirection::tryFrom($data['direction']);
|
return $data;
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => PredictionSource::Llm,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $data['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function prompt(string $priceList): string
|
|
||||||
{
|
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
|
||||||
{$priceList}
|
|
||||||
|
|
||||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
|
||||||
and a one-sentence reasoning.
|
|
||||||
PROMPT;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Services\ApiLogger;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class OpenAiPredictionProvider implements OilPredictionProvider
|
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
protected function apiKey(): ?string
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
|
||||||
{
|
{
|
||||||
if (! config('services.openai.api_key')) {
|
return config('services.openai.api_key');
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$priceList = $prices->sortBy('date')
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
{
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
$url = 'https://api.openai.com/v1/chat/completions';
|
$url = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
||||||
->withToken(config('services.openai.api_key'))
|
->withToken($apiKey)
|
||||||
->post($url, [
|
->post($url, [
|
||||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||||
'response_format' => [
|
'response_format' => [
|
||||||
@@ -56,12 +39,12 @@ class OpenAiPredictionProvider implements OilPredictionProvider
|
|||||||
],
|
],
|
||||||
'messages' => [[
|
'messages' => [[
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
'content' => $this->prompt($priceList),
|
'content' => $this->defaultPrompt($priceList),
|
||||||
]],
|
]],
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
|
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -69,45 +52,11 @@ class OpenAiPredictionProvider implements OilPredictionProvider
|
|||||||
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
|
Log::error(self::class.': unexpected response format', ['data' => $data]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$direction = TrendDirection::tryFrom($data['direction']);
|
return $data;
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => PredictionSource::Llm,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $data['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function prompt(string $priceList): string
|
|
||||||
{
|
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
|
||||||
{$priceList}
|
|
||||||
|
|
||||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
|
||||||
and a one-sentence reasoning.
|
|
||||||
PROMPT;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,31 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Enums\FuelType;
|
use App\Enums\FuelType;
|
||||||
use App\Models\StationPriceCurrent;
|
use App\Models\StationPriceCurrent;
|
||||||
|
use App\Services\Prediction\Signals\BrandBehaviourSignal;
|
||||||
|
use App\Services\Prediction\Signals\DayOfWeekSignal;
|
||||||
|
use App\Services\Prediction\Signals\OilSignal;
|
||||||
|
use App\Services\Prediction\Signals\RegionalMomentumSignal;
|
||||||
|
use App\Services\Prediction\Signals\SignalContext;
|
||||||
|
use App\Services\Prediction\Signals\StickinessSignal;
|
||||||
|
use App\Services\Prediction\Signals\TrendSignal;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class NationalFuelPredictionService
|
class NationalFuelPredictionService
|
||||||
{
|
{
|
||||||
private const float R_SQUARED_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
/** Slope (pence/day) at which trend score saturates to ±1.0. */
|
|
||||||
private const float SLOPE_SATURATION_PENCE = 0.5;
|
|
||||||
|
|
||||||
/** Minimum unique days of history for the day-of-week signal to activate. */
|
|
||||||
private const int DAY_OF_WEEK_MIN_DAYS = 21;
|
|
||||||
|
|
||||||
private const int PREDICTION_HORIZON_DAYS = 7;
|
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TrendSignal $trendSignal,
|
||||||
|
private readonly DayOfWeekSignal $dayOfWeekSignal,
|
||||||
|
private readonly BrandBehaviourSignal $brandBehaviourSignal,
|
||||||
|
private readonly StickinessSignal $stickinessSignal,
|
||||||
|
private readonly RegionalMomentumSignal $regionalMomentumSignal,
|
||||||
|
private readonly OilSignal $oilSignal,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* fuel_type: string,
|
* fuel_type: string,
|
||||||
@@ -41,18 +49,17 @@ class NationalFuelPredictionService
|
|||||||
{
|
{
|
||||||
$fuelType = FuelType::E10;
|
$fuelType = FuelType::E10;
|
||||||
$hasCoordinates = $lat !== null && $lng !== null;
|
$hasCoordinates = $lat !== null && $lng !== null;
|
||||||
|
$context = new SignalContext($fuelType, $lat, $lng);
|
||||||
|
|
||||||
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
||||||
$trend = $this->computeTrendSignal($fuelType);
|
$trend = $this->trendSignal->compute($context);
|
||||||
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
|
||||||
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
|
||||||
$stickiness = $this->computeStickinessSignal($fuelType);
|
$stickiness = $this->stickinessSignal->compute($context);
|
||||||
$oil = $this->computeOilSignal();
|
$oil = $this->oilSignal->compute($context);
|
||||||
|
|
||||||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||||
$regionalMomentum = $hasCoordinates
|
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
|
||||||
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
|
||||||
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
|
||||||
|
|
||||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
||||||
|
|
||||||
@@ -103,10 +110,12 @@ class NationalFuelPredictionService
|
|||||||
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
||||||
{
|
{
|
||||||
if ($lat !== null && $lng !== null) {
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
$avg = DB::table('station_prices_current')
|
$avg = DB::table('station_prices_current')
|
||||||
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
->where('station_prices_current.fuel_type', $fuelType->value)
|
->where('station_prices_current.fuel_type', $fuelType->value)
|
||||||
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
->avg('station_prices_current.price_pence');
|
->avg('station_prices_current.price_pence');
|
||||||
|
|
||||||
if ($avg !== null) {
|
if ($avg !== null) {
|
||||||
@@ -119,367 +128,6 @@ class NationalFuelPredictionService
|
|||||||
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Linear regression on daily national average prices.
|
|
||||||
* Tries 5-day lookback first; falls back to 14-day if R² < threshold.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float}
|
|
||||||
*/
|
|
||||||
private function computeTrendSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
foreach ([5, 14] as $lookbackDays) {
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
|
||||||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
|
||||||
->groupBy('day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
|
||||||
|
|
||||||
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
|
||||||
$slope = $regression['slope'];
|
|
||||||
$direction = match (true) {
|
|
||||||
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
|
||||||
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
$absSlope = abs($slope);
|
|
||||||
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
|
|
||||||
$projected = round($slope * $lookbackDays, 1);
|
|
||||||
$detail = $direction === 'stable'
|
|
||||||
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
|
||||||
: sprintf(
|
|
||||||
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
|
||||||
$slope > 0 ? 'Rising' : 'Falling',
|
|
||||||
abs(round($slope, 2)),
|
|
||||||
$lookbackDays,
|
|
||||||
round($regression['r_squared'], 2),
|
|
||||||
$projected > 0 ? '+' : '',
|
|
||||||
$projected,
|
|
||||||
self::PREDICTION_HORIZON_DAYS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($lookbackDays === 5) {
|
|
||||||
$detail .= ' [Adaptive lookback active]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $regression['r_squared']),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => $detail,
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
'slope' => round($slope, 3),
|
|
||||||
'r_squared' => round($regression['r_squared'], 3),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => 0.0,
|
|
||||||
'confidence' => 0.0,
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
|
||||||
'data_points' => 0,
|
|
||||||
'enabled' => false,
|
|
||||||
'slope' => 0.0,
|
|
||||||
'r_squared' => 0.0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare today's average price against the per-weekday average over 90 days.
|
|
||||||
* Requires 56+ days of history to activate.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeDayOfWeekSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
|
||||||
$dowExpr = $isSqlite
|
|
||||||
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
|
|
||||||
: 'DAYOFWEEK(price_effective_at)';
|
|
||||||
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays(90))
|
|
||||||
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
|
||||||
->groupBy('dow', 'day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$uniqueDays = $rows->pluck('day')->unique()->count();
|
|
||||||
|
|
||||||
if ($uniqueDays < self::DAY_OF_WEEK_MIN_DAYS) {
|
|
||||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_MIN_DAYS.')');
|
|
||||||
}
|
|
||||||
|
|
||||||
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
|
||||||
$weekAvg = $dowAverages->avg();
|
|
||||||
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
|
||||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
|
||||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
|
||||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
||||||
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
|
||||||
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
|
||||||
|
|
||||||
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
|
||||||
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
|
||||||
|
|
||||||
$direction = match (true) {
|
|
||||||
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
|
||||||
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
|
|
||||||
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
|
||||||
|
|
||||||
$parts = [];
|
|
||||||
$parts[] = abs($todayDeltaPence) < 0.1
|
|
||||||
? "Today ({$todayName}) is typically in line with the weekly average."
|
|
||||||
: sprintf(
|
|
||||||
'Today (%s) is typically %sp %s the weekly average.',
|
|
||||||
$todayName,
|
|
||||||
number_format(abs($todayDeltaPence), 1),
|
|
||||||
$todayDeltaPence > 0 ? 'above' : 'below',
|
|
||||||
);
|
|
||||||
|
|
||||||
$parts[] = abs($tomorrowDeltaPence) < 0.1
|
|
||||||
? "Tomorrow ({$tomorrowName}) is typically the same."
|
|
||||||
: sprintf(
|
|
||||||
'Tomorrow (%s) is typically %sp %s.',
|
|
||||||
$tomorrowName,
|
|
||||||
number_format(abs($tomorrowDeltaPence), 1),
|
|
||||||
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($cheapestDow === $todayDow) {
|
|
||||||
$parts[] = 'Today is historically the cheapest day of the week.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $uniqueDays / 90),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => implode(' ', $parts),
|
|
||||||
'data_points' => $uniqueDays,
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare supermarket vs non-supermarket 7-day price trend.
|
|
||||||
* Detects divergence where one group has moved but the other hasn't yet.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeBrandBehaviourSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
|
||||||
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
|
||||||
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
|
||||||
->groupBy('stations.is_supermarket', 'day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$supermarket = $rows->where('is_supermarket', 1)->values();
|
|
||||||
$major = $rows->where('is_supermarket', 0)->values();
|
|
||||||
|
|
||||||
if ($supermarket->count() < 2 || $major->count() < 2) {
|
|
||||||
return $this->disabledSignal('Insufficient brand data for comparison');
|
|
||||||
}
|
|
||||||
|
|
||||||
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
|
||||||
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
|
||||||
|
|
||||||
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
|
||||||
$supermarketChange = round($supermarketSlope * 7, 1);
|
|
||||||
$majorChange = round($majorSlope * 7, 1);
|
|
||||||
|
|
||||||
if ($divergence < 1.0) {
|
|
||||||
return [
|
|
||||||
'score' => 0.0,
|
|
||||||
'confidence' => 0.5,
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => 'Supermarkets and majors moving in sync.',
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
|
||||||
$direction = $leaderChange > 0 ? 'up' : 'down';
|
|
||||||
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
|
||||||
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
|
||||||
$leaderAbs = abs($leaderChange);
|
|
||||||
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $direction === 'up' ? 1.0 : -1.0,
|
|
||||||
'confidence' => min(1.0, $divergence / 5.0),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Average hold duration (days between price changes) as a confidence modifier.
|
|
||||||
* Requires 30+ days of history. Returns a score between -0.1 and +0.1.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeStickinessSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
|
||||||
$diffExpr = $isSqlite
|
|
||||||
? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)'
|
|
||||||
: 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))';
|
|
||||||
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays(30))
|
|
||||||
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
|
||||||
->groupBy('station_id')
|
|
||||||
->having('changes', '>', 1)
|
|
||||||
->having('span_days', '>', 0)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 10) {
|
|
||||||
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
|
||||||
$avgHoldDays = round((float) $avgHoldDays, 1);
|
|
||||||
|
|
||||||
$score = match (true) {
|
|
||||||
$avgHoldDays < 2 => -0.1,
|
|
||||||
$avgHoldDays > 5 => 0.1,
|
|
||||||
default => 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
$detail = match (true) {
|
|
||||||
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
|
||||||
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
|
||||||
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $rows->count() / 200),
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => $detail,
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for regional momentum signal (requires lat/lng).
|
|
||||||
* Compares local station prices vs national average trend.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array
|
|
||||||
{
|
|
||||||
// Regional momentum: compare trend of stations within 50km vs national trend
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
|
||||||
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
|
||||||
->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $lat, $lng, $lat])
|
|
||||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
|
||||||
->groupBy('day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 3) {
|
|
||||||
return $this->disabledSignal('Insufficient regional data');
|
|
||||||
}
|
|
||||||
|
|
||||||
$regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
|
||||||
$direction = match (true) {
|
|
||||||
$regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
|
||||||
$regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
|
||||||
'confidence' => min(1.0, $regionalRegression['r_squared']),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')',
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback)
|
|
||||||
* covering today or later. Sourced from price_predictions, which OilPriceService
|
|
||||||
* populates daily.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeOilSignal(): array
|
|
||||||
{
|
|
||||||
$prediction = null;
|
|
||||||
|
|
||||||
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
|
|
||||||
$prediction = DB::table('price_predictions')
|
|
||||||
->where('source', $source)
|
|
||||||
->where('predicted_for', '>=', now()->toDateString())
|
|
||||||
->orderByDesc('predicted_for')
|
|
||||||
->orderByDesc('generated_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($prediction !== null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($prediction === null) {
|
|
||||||
return $this->disabledSignal('No oil price prediction available');
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = match ($prediction->direction) {
|
|
||||||
'rising' => 'up',
|
|
||||||
'falling' => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
|
|
||||||
$score = match ($direction) {
|
|
||||||
'up' => 1.0,
|
|
||||||
'down' => -1.0,
|
|
||||||
default => 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
$confidence = round(((float) $prediction->confidence) / 100, 2);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => $confidence,
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => sprintf(
|
|
||||||
'Brent crude %s (%s, %d%% confidence)',
|
|
||||||
$prediction->direction,
|
|
||||||
$prediction->source,
|
|
||||||
(int) $prediction->confidence,
|
|
||||||
),
|
|
||||||
'data_points' => 1,
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||||||
private function disabledSignal(string $detail): array
|
private function disabledSignal(string $detail): array
|
||||||
{
|
{
|
||||||
@@ -668,11 +316,13 @@ class NationalFuelPredictionService
|
|||||||
$dateString = $date->toDateString();
|
$dateString = $date->toDateString();
|
||||||
|
|
||||||
if ($lat !== null && $lng !== null) {
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
$regional = DB::table('station_prices')
|
$regional = DB::table('station_prices')
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
->where('station_prices.fuel_type', $fuelType->value)
|
||||||
->whereDate('station_prices.price_effective_at', $dateString)
|
->whereDate('station_prices.price_effective_at', $dateString)
|
||||||
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
->avg('station_prices.price_pence');
|
->avg('station_prices.price_pence');
|
||||||
|
|
||||||
if ($regional !== null) {
|
if ($regional !== null) {
|
||||||
@@ -697,11 +347,13 @@ class NationalFuelPredictionService
|
|||||||
$usedRegional = false;
|
$usedRegional = false;
|
||||||
|
|
||||||
if ($lat !== null && $lng !== null) {
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
$rows = DB::table('station_prices')
|
$rows = DB::table('station_prices')
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
->where('station_prices.fuel_type', $fuelType->value)
|
||||||
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||||||
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
->groupBy('day')
|
->groupBy('day')
|
||||||
->orderBy('day')
|
->orderBy('day')
|
||||||
@@ -728,47 +380,6 @@ class NationalFuelPredictionService
|
|||||||
return [$series, $usedRegional];
|
return [$series, $usedRegional];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Least-squares linear regression.
|
|
||||||
* x is the array index (day number), y is the price value.
|
|
||||||
*
|
|
||||||
* @param float[] $values
|
|
||||||
* @return array{slope: float, r_squared: float}
|
|
||||||
*/
|
|
||||||
private function linearRegression(array $values): array
|
|
||||||
{
|
|
||||||
$n = count($values);
|
|
||||||
if ($n < 2) {
|
|
||||||
return ['slope' => 0.0, 'r_squared' => 0.0];
|
|
||||||
}
|
|
||||||
|
|
||||||
$xMean = ($n - 1) / 2.0;
|
|
||||||
$yMean = array_sum($values) / $n;
|
|
||||||
|
|
||||||
$numerator = 0.0;
|
|
||||||
$denominator = 0.0;
|
|
||||||
|
|
||||||
foreach ($values as $i => $y) {
|
|
||||||
$x = $i - $xMean;
|
|
||||||
$numerator += $x * ($y - $yMean);
|
|
||||||
$denominator += $x * $x;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
|
||||||
|
|
||||||
$ssRes = 0.0;
|
|
||||||
$ssTot = 0.0;
|
|
||||||
foreach ($values as $i => $y) {
|
|
||||||
$predicted = $yMean + $slope * ($i - $xMean);
|
|
||||||
$ssRes += ($y - $predicted) ** 2;
|
|
||||||
$ssTot += ($y - $yMean) ** 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
|
||||||
|
|
||||||
return ['slope' => $slope, 'r_squared' => $rSquared];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{enabled: bool, detail: string, direction: string} $trend
|
* @param array{enabled: bool, detail: string, direction: string} $trend
|
||||||
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
||||||
|
|||||||
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
abstract class AbstractSignal implements Signal
|
||||||
|
{
|
||||||
|
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
|
||||||
|
protected function disabledSignal(string $detail): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.0,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => 0,
|
||||||
|
'enabled' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least-squares linear regression. x = array index, y = value.
|
||||||
|
*
|
||||||
|
* @param float[] $values
|
||||||
|
* @return array{slope: float, r_squared: float}
|
||||||
|
*/
|
||||||
|
protected function linearRegression(array $values): array
|
||||||
|
{
|
||||||
|
$n = count($values);
|
||||||
|
|
||||||
|
if ($n < 2) {
|
||||||
|
return ['slope' => 0.0, 'r_squared' => 0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$xMean = ($n - 1) / 2.0;
|
||||||
|
$yMean = array_sum($values) / $n;
|
||||||
|
|
||||||
|
$numerator = 0.0;
|
||||||
|
$denominator = 0.0;
|
||||||
|
|
||||||
|
foreach ($values as $i => $y) {
|
||||||
|
$x = $i - $xMean;
|
||||||
|
$numerator += $x * ($y - $yMean);
|
||||||
|
$denominator += $x * $x;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
||||||
|
|
||||||
|
$ssRes = 0.0;
|
||||||
|
$ssTot = 0.0;
|
||||||
|
|
||||||
|
foreach ($values as $i => $y) {
|
||||||
|
$predicted = $yMean + $slope * ($i - $xMean);
|
||||||
|
$ssRes += ($y - $predicted) ** 2;
|
||||||
|
$ssTot += ($y - $yMean) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
||||||
|
|
||||||
|
return ['slope' => $slope, 'r_squared' => $rSquared];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class BrandBehaviourSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||||
|
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
||||||
|
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
|
->groupBy('stations.is_supermarket', 'day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$supermarket = $rows->where('is_supermarket', 1)->values();
|
||||||
|
$major = $rows->where('is_supermarket', 0)->values();
|
||||||
|
|
||||||
|
if ($supermarket->count() < 2 || $major->count() < 2) {
|
||||||
|
return $this->disabledSignal('Insufficient brand data for comparison');
|
||||||
|
}
|
||||||
|
|
||||||
|
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||||
|
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||||
|
|
||||||
|
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
||||||
|
$supermarketChange = round($supermarketSlope * 7, 1);
|
||||||
|
$majorChange = round($majorSlope * 7, 1);
|
||||||
|
|
||||||
|
if ($divergence < 1.0) {
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.5,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => 'Supermarkets and majors moving in sync.',
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
||||||
|
$direction = $leaderChange > 0 ? 'up' : 'down';
|
||||||
|
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
||||||
|
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
||||||
|
$leaderAbs = abs($leaderChange);
|
||||||
|
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $direction === 'up' ? 1.0 : -1.0,
|
||||||
|
'confidence' => min(1.0, $divergence / 5.0),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class DayOfWeekSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const int MIN_DAYS = 21;
|
||||||
|
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays(90))
|
||||||
|
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
||||||
|
->groupBy('dow', 'day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$uniqueDays = $rows->pluck('day')->unique()->count();
|
||||||
|
|
||||||
|
if ($uniqueDays < self::MIN_DAYS) {
|
||||||
|
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
||||||
|
$weekAvg = $dowAverages->avg();
|
||||||
|
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
||||||
|
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||||
|
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||||
|
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
||||||
|
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
||||||
|
|
||||||
|
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
||||||
|
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||||
|
|
||||||
|
$direction = match (true) {
|
||||||
|
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
||||||
|
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = abs($todayDeltaPence) < 0.1
|
||||||
|
? "Today ({$todayName}) is typically in line with the weekly average."
|
||||||
|
: sprintf(
|
||||||
|
'Today (%s) is typically %sp %s the weekly average.',
|
||||||
|
$todayName,
|
||||||
|
number_format(abs($todayDeltaPence), 1),
|
||||||
|
$todayDeltaPence > 0 ? 'above' : 'below',
|
||||||
|
);
|
||||||
|
|
||||||
|
$parts[] = abs($tomorrowDeltaPence) < 0.1
|
||||||
|
? "Tomorrow ({$tomorrowName}) is typically the same."
|
||||||
|
: sprintf(
|
||||||
|
'Tomorrow (%s) is typically %sp %s.',
|
||||||
|
$tomorrowName,
|
||||||
|
number_format(abs($tomorrowDeltaPence), 1),
|
||||||
|
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($cheapestDow === $todayDow) {
|
||||||
|
$parts[] = 'Today is historically the cheapest day of the week.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $uniqueDays / 90),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => implode(' ', $parts),
|
||||||
|
'data_points' => $uniqueDays,
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL dialect helpers for the small set of MySQL/SQLite differences the
|
||||||
|
* signal classes care about. Centralises the isSqlite ternaries that were
|
||||||
|
* duplicated across DayOfWeekSignal and StickinessSignal.
|
||||||
|
*/
|
||||||
|
final class DbDialect
|
||||||
|
{
|
||||||
|
private static function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return DB::connection()->getDriverName() === 'sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
|
||||||
|
* Targets a column on the queried table.
|
||||||
|
*/
|
||||||
|
public static function dayOfWeekExpr(string $column): string
|
||||||
|
{
|
||||||
|
return self::isSqlite()
|
||||||
|
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
|
||||||
|
: "DAYOFWEEK({$column})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole-day difference between MAX and MIN of a datetime column, suitable
|
||||||
|
* for use in an aggregate selectRaw.
|
||||||
|
*/
|
||||||
|
public static function maxMinDayDiffExpr(string $column): string
|
||||||
|
{
|
||||||
|
return self::isSqlite()
|
||||||
|
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
|
||||||
|
: "DATEDIFF(MAX({$column}), MIN({$column}))";
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class OilSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
|
||||||
|
* fallback) covering today or later. Sourced from price_predictions,
|
||||||
|
* which OilPriceService populates daily.
|
||||||
|
*/
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$prediction = null;
|
||||||
|
|
||||||
|
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
|
||||||
|
$prediction = DB::table('price_predictions')
|
||||||
|
->where('source', $source)
|
||||||
|
->where('predicted_for', '>=', now()->toDateString())
|
||||||
|
->orderByDesc('predicted_for')
|
||||||
|
->orderByDesc('generated_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($prediction !== null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prediction === null) {
|
||||||
|
return $this->disabledSignal('No oil price prediction available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = match ($prediction->direction) {
|
||||||
|
'rising' => 'up',
|
||||||
|
'falling' => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
$score = match ($direction) {
|
||||||
|
'up' => 1.0,
|
||||||
|
'down' => -1.0,
|
||||||
|
default => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$confidence = round(((float) $prediction->confidence) / 100, 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => sprintf(
|
||||||
|
'Brent crude %s (%s, %d%% confidence)',
|
||||||
|
$prediction->direction,
|
||||||
|
$prediction->source,
|
||||||
|
(int) $prediction->confidence,
|
||||||
|
),
|
||||||
|
'data_points' => 1,
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use App\Services\HaversineQuery;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class RegionalMomentumSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
|
private const float REGIONAL_RADIUS_KM = 50.0;
|
||||||
|
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
if (! $context->hasCoordinates()) {
|
||||||
|
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||||
|
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
||||||
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
|
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 3) {
|
||||||
|
return $this->disabledSignal('Insufficient regional data');
|
||||||
|
}
|
||||||
|
|
||||||
|
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||||
|
$direction = match (true) {
|
||||||
|
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||||
|
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
||||||
|
'confidence' => min(1.0, $regression['r_squared']),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/Signal.php
Normal file
24
app/Services/Prediction/Signals/Signal.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
interface Signal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluate the signal against the given context.
|
||||||
|
*
|
||||||
|
* Returns the canonical signal payload. Implementations may add extra
|
||||||
|
* keys beyond the base shape (e.g. trend adds slope + r_squared).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* score: float,
|
||||||
|
* confidence: float,
|
||||||
|
* direction: string,
|
||||||
|
* detail: string,
|
||||||
|
* data_points: int,
|
||||||
|
* enabled: bool,
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function compute(SignalContext $context): array;
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputs required to evaluate a prediction signal. Individual signals may
|
||||||
|
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
|
||||||
|
* RegionalMomentumSignal requires lat/lng to be non-null.
|
||||||
|
*/
|
||||||
|
final readonly class SignalContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public FuelType $fuelType,
|
||||||
|
public ?float $lat = null,
|
||||||
|
public ?float $lng = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasCoordinates(): bool
|
||||||
|
{
|
||||||
|
return $this->lat !== null && $this->lng !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class StickinessSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays(30))
|
||||||
|
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
||||||
|
->groupBy('station_id')
|
||||||
|
->having('changes', '>', 1)
|
||||||
|
->having('span_days', '>', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 10) {
|
||||||
|
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
||||||
|
$avgHoldDays = round((float) $avgHoldDays, 1);
|
||||||
|
|
||||||
|
$score = match (true) {
|
||||||
|
$avgHoldDays < 2 => -0.1,
|
||||||
|
$avgHoldDays > 5 => 0.1,
|
||||||
|
default => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$detail = match (true) {
|
||||||
|
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
||||||
|
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
||||||
|
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $rows->count() / 200),
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class TrendSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const float R_SQUARED_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
|
private const float SLOPE_SATURATION_PENCE = 0.5;
|
||||||
|
|
||||||
|
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
foreach ([5, 14] as $lookbackDays) {
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
||||||
|
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||||
|
|
||||||
|
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
||||||
|
$slope = $regression['slope'];
|
||||||
|
$direction = match (true) {
|
||||||
|
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||||
|
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
$absSlope = abs($slope);
|
||||||
|
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
|
||||||
|
$projected = round($slope * $lookbackDays, 1);
|
||||||
|
$detail = $direction === 'stable'
|
||||||
|
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
||||||
|
: sprintf(
|
||||||
|
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
||||||
|
$slope > 0 ? 'Rising' : 'Falling',
|
||||||
|
abs(round($slope, 2)),
|
||||||
|
$lookbackDays,
|
||||||
|
round($regression['r_squared'], 2),
|
||||||
|
$projected > 0 ? '+' : '',
|
||||||
|
$projected,
|
||||||
|
self::PREDICTION_HORIZON_DAYS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lookbackDays === 5) {
|
||||||
|
$detail .= ' [Adaptive lookback active]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $regression['r_squared']),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
'slope' => round($slope, 3),
|
||||||
|
'r_squared' => round($regression['r_squared'], 3),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.0,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
||||||
|
'data_points' => 0,
|
||||||
|
'enabled' => false,
|
||||||
|
'slope' => 0.0,
|
||||||
|
'r_squared' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Services/StationSearch/SearchCriteria.php
Normal file
16
app/Services/StationSearch/SearchCriteria.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
|
||||||
|
final readonly class SearchCriteria
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public float $lat,
|
||||||
|
public float $lng,
|
||||||
|
public FuelType $fuelType,
|
||||||
|
public float $radiusKm,
|
||||||
|
public string $sort,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
21
app/Services/StationSearch/SearchResult.php
Normal file
21
app/Services/StationSearch/SearchResult.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final readonly class SearchResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $stations Sorted station rows with _updated_at/_reliability/_classification cached
|
||||||
|
* @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary
|
||||||
|
* @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts
|
||||||
|
* @param array<string, mixed> $prediction
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Collection $stations,
|
||||||
|
public array $pricesSummary,
|
||||||
|
public array $reliabilityCounts,
|
||||||
|
public array $prediction,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
151
app/Services/StationSearch/StationSearchService.php
Normal file
151
app/Services/StationSearch/StationSearchService.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use App\Enums\PriceClassification;
|
||||||
|
use App\Enums\PriceReliability;
|
||||||
|
use App\Models\Search;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\HaversineQuery;
|
||||||
|
use App\Services\NationalFuelPredictionService;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class StationSearchService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NationalFuelPredictionService $predictionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||||
|
{
|
||||||
|
$stations = $this->fetchAndSortStations($criteria);
|
||||||
|
$prices = $stations->pluck('price_pence');
|
||||||
|
|
||||||
|
$this->logSearch($criteria, $stations->count(), $prices, $ipHash);
|
||||||
|
|
||||||
|
return new SearchResult(
|
||||||
|
stations: $stations,
|
||||||
|
pricesSummary: [
|
||||||
|
'lowest' => $prices->min(),
|
||||||
|
'highest' => $prices->max(),
|
||||||
|
'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
],
|
||||||
|
reliabilityCounts: $this->countReliability($stations),
|
||||||
|
prediction: $this->buildPrediction($user, $criteria),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, mixed> */
|
||||||
|
private function fetchAndSortStations(SearchCriteria $criteria): Collection
|
||||||
|
{
|
||||||
|
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng);
|
||||||
|
|
||||||
|
$all = Station::query()
|
||||||
|
->selectRaw(
|
||||||
|
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
|
||||||
|
$distanceBindings,
|
||||||
|
)
|
||||||
|
->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void {
|
||||||
|
$join->on('stations.node_id', '=', 'spc.station_id')
|
||||||
|
->where('spc.fuel_type', '=', $criteria->fuelType->value);
|
||||||
|
})
|
||||||
|
->where('stations.temporary_closure', false)
|
||||||
|
->where('stations.permanent_closure', false)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Compute reliability + classification once per row so the sort, the
|
||||||
|
// count groupBy, and the StationResource render all read cached
|
||||||
|
// values instead of re-invoking PriceReliability::fromUpdatedAt.
|
||||||
|
$all->each(function ($s): void {
|
||||||
|
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
|
||||||
|
$s->_updated_at = $updatedAt;
|
||||||
|
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
||||||
|
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm);
|
||||||
|
|
||||||
|
return $this->applySort($filtered, $criteria->sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $filtered
|
||||||
|
* @return Collection<int, mixed>
|
||||||
|
*/
|
||||||
|
private function applySort(Collection $filtered, string $sort): Collection
|
||||||
|
{
|
||||||
|
if ($sort === 'reliable') {
|
||||||
|
return $filtered
|
||||||
|
->sort(function ($a, $b) {
|
||||||
|
return $a->_reliability->weight() <=> $b->_reliability->weight()
|
||||||
|
?: ((int) $a->price_pence <=> (int) $b->price_pence)
|
||||||
|
?: ((float) $a->distance_km <=> (float) $b->distance_km);
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $stations
|
||||||
|
* @return array{reliable: int, stale: int, outdated: int}
|
||||||
|
*/
|
||||||
|
private function countReliability(Collection $stations): array
|
||||||
|
{
|
||||||
|
$counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0),
|
||||||
|
'stale' => (int) $counts->get(PriceReliability::Stale->value, 0),
|
||||||
|
'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, mixed> $prices */
|
||||||
|
private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void
|
||||||
|
{
|
||||||
|
Search::create([
|
||||||
|
'lat_bucket' => round($criteria->lat, 2),
|
||||||
|
'lng_bucket' => round($criteria->lng, 2),
|
||||||
|
'fuel_type' => $criteria->fuelType->value,
|
||||||
|
'results_count' => $resultsCount,
|
||||||
|
'lowest_pence' => $prices->min(),
|
||||||
|
'highest_pence' => $prices->max(),
|
||||||
|
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
'searched_at' => now(),
|
||||||
|
'ip_hash' => $ipHash ?? hash('sha256', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 buildPrediction(?User $user, SearchCriteria $criteria): array
|
||||||
|
{
|
||||||
|
$result = $this->predictionService->predict($criteria->lat, $criteria->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 $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,18 @@ return [
|
|||||||
'api_key' => env('FUELALERT_API_KEY'),
|
'api_key' => env('FUELALERT_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'onesignal' => [
|
||||||
|
'app_id' => env('ONESIGNAL_APP_ID'),
|
||||||
|
'api_key' => env('ONESIGNAL_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'vonage' => [
|
||||||
|
'key' => env('VONAGE_KEY'),
|
||||||
|
'secret' => env('VONAGE_SECRET'),
|
||||||
|
'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'),
|
||||||
|
'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'),
|
||||||
|
],
|
||||||
|
|
||||||
'stripe' => [
|
'stripe' => [
|
||||||
'prices' => [
|
'prices' => [
|
||||||
'basic' => [
|
'basic' => [
|
||||||
|
|||||||
@@ -448,12 +448,12 @@ const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
|||||||
const { stations, meta, prediction, loading, error, search, reset } = useStations()
|
const { stations, meta, prediction, loading, error, search, reset } = useStations()
|
||||||
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
||||||
|
|
||||||
watch(loading, (isLoading) => {
|
// watch(loading, (isLoading) => {
|
||||||
if (!isLoading) return
|
// if (!isLoading) return
|
||||||
nextTick(() => {
|
// nextTick(() => {
|
||||||
window.scrollBy({ top: 40, behavior: 'smooth' })
|
// window.scrollBy({ top: 40, behavior: 'smooth' })
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
const searchOrigin = computed(() => {
|
const searchOrigin = computed(() => {
|
||||||
if (meta.value?.lat != null && meta.value?.lng != null) {
|
if (meta.value?.lat != null && meta.value?.lng != null) {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ Schedule::command('oil:predict --fetch')
|
|||||||
->onOneServer()
|
->onOneServer()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Move station_prices rows older than 12 months into station_prices_archive
|
||||||
|
// once a month. Keeps the partitioned hot table bounded.
|
||||||
|
Schedule::command('fuel:archive')
|
||||||
|
->monthlyOn(1, '04:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
// Scheduled WhatsApp updates — morning and evening
|
// Scheduled WhatsApp updates — morning and evening
|
||||||
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
||||||
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
||||||
|
|||||||
69
tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Normal file
69
tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\StationPrice;
|
||||||
|
use App\Models\StationPriceArchive;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('moves prices older than 12 months to archive', function (): void {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'price_effective_at' => now()->subMonths(13),
|
||||||
|
'price_reported_at' => now()->subMonths(13),
|
||||||
|
'recorded_at' => now()->subMonths(13),
|
||||||
|
]);
|
||||||
|
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'price_effective_at' => now()->subMonths(6),
|
||||||
|
'price_reported_at' => now()->subMonths(6),
|
||||||
|
'recorded_at' => now()->subMonths(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('fuel:archive')->assertSuccessful();
|
||||||
|
|
||||||
|
expect(StationPrice::count())->toBe(1)
|
||||||
|
->and(StationPriceArchive::count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs no-op message when nothing qualifies', function (): void {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'price_effective_at' => now()->subMonths(3),
|
||||||
|
'price_reported_at' => now()->subMonths(3),
|
||||||
|
'recorded_at' => now()->subMonths(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('fuel:archive')
|
||||||
|
->expectsOutputToContain('No prices to archive.')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(StationPrice::count())->toBe(1)
|
||||||
|
->and(StationPriceArchive::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the row data when archiving', function (): void {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
$original = StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'price_pence' => 14523,
|
||||||
|
'price_effective_at' => now()->subMonths(13),
|
||||||
|
'price_reported_at' => now()->subMonths(13),
|
||||||
|
'recorded_at' => now()->subMonths(13),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('fuel:archive')->assertSuccessful();
|
||||||
|
|
||||||
|
$archived = StationPriceArchive::first();
|
||||||
|
|
||||||
|
expect($archived)->not->toBeNull()
|
||||||
|
->and($archived->station_id)->toBe($original->station_id)
|
||||||
|
->and($archived->price_pence)->toBe(14523);
|
||||||
|
});
|
||||||
@@ -7,7 +7,9 @@ use App\Models\NotificationLog;
|
|||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserNotificationPreference;
|
use App\Models\UserNotificationPreference;
|
||||||
|
use App\Notifications\FuelPriceAlert;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@@ -39,6 +41,39 @@ it('logs a sent entry for each allowed channel', function (): void {
|
|||||||
->and($log->fuel_type)->toBe(FuelType::E10->value);
|
->and($log->fuel_type)->toBe(FuelType::E10->value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('actually dispatches FuelPriceAlert with the allowed channels', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
UserNotificationPreference::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'channel' => 'email',
|
||||||
|
'fuel_type' => FuelType::E10->value,
|
||||||
|
'enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, FuelPriceAlert::class, function (FuelPriceAlert $n) {
|
||||||
|
return $n->triggerType === 'price_threshold'
|
||||||
|
&& $n->fuelType === FuelType::E10->value
|
||||||
|
&& $n->price === 143.9
|
||||||
|
&& in_array('email', $n->channels, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch FuelPriceAlert when no channels are allowed', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
// Free user with no preferences — channelsFor returns []
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
|
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
|
||||||
|
|
||||||
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
|
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
|
||||||
|
|||||||
@@ -3,10 +3,26 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// SQLite lacks GREATEST/LEAST scalar functions — register PHP-backed
|
||||||
|
// shims so the haversine and other math expressions used in
|
||||||
|
// production-style queries run identically in :memory: tests.
|
||||||
|
// Idempotent: registering twice on the same PDO is harmless.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
|
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
|
||||||
{
|
{
|
||||||
if (! Features::enabled($feature)) {
|
if (! Features::enabled($feature)) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ it('returns null when fewer than 14 prices are available for EWMA', function ():
|
|||||||
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
|
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores both EWMA and LLM predictions when provider succeeds', function (): void {
|
it('stores only the LLM prediction when the provider succeeds', function (): void {
|
||||||
seedPrices(20);
|
seedPrices(20);
|
||||||
|
|
||||||
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
|
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
|
||||||
@@ -76,7 +76,8 @@ it('stores both EWMA and LLM predictions when provider succeeds', function (): v
|
|||||||
$prediction = $this->predictor->generatePrediction();
|
$prediction = $this->predictor->generatePrediction();
|
||||||
|
|
||||||
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
||||||
->and(PricePrediction::count())->toBe(2);
|
->and(PricePrediction::count())->toBe(1)
|
||||||
|
->and(PricePrediction::where('source', PredictionSource::Ewma)->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to EWMA when provider returns null', function (): void {
|
it('falls back to EWMA when provider returns null', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user