Compare commits
7 Commits
70cb40ff5d
...
3ccdc28763
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ccdc28763 | ||
|
|
0bea50b843 | ||
|
|
8bd43ee9e4 | ||
|
|
cf6a1369d4 | ||
|
|
1c548eae87 | ||
|
|
a30dbdfbba | ||
|
|
c815597a98 |
@@ -15,4 +15,17 @@ enum FuelType: string
|
|||||||
{
|
{
|
||||||
return self::from(strtolower($value));
|
return self::from(strtolower($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function fromAlias(string $alias): self
|
||||||
|
{
|
||||||
|
return match (strtolower($alias)) {
|
||||||
|
'diesel', 'b7_standard' => self::B7Standard,
|
||||||
|
'premium_diesel', 'b7_premium' => self::B7Premium,
|
||||||
|
'petrol', 'unleaded', 'e10' => self::E10,
|
||||||
|
'premium_unleaded', 'e5' => self::E5,
|
||||||
|
'b10' => self::B10,
|
||||||
|
'hvo' => self::Hvo,
|
||||||
|
default => throw new \ValueError("Unknown fuel type alias: {$alias}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ namespace App\Enums;
|
|||||||
enum PredictionSource: string
|
enum PredictionSource: string
|
||||||
{
|
{
|
||||||
case Llm = 'llm';
|
case Llm = 'llm';
|
||||||
|
case LlmWithContext = 'llm_with_context';
|
||||||
case Ewma = 'ewma';
|
case Ewma = 'ewma';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,14 @@ class OilPredictionResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('source')
|
TextColumn::make('source')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
|
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
||||||
|
PredictionSource::Llm => 'LLM',
|
||||||
|
PredictionSource::LlmWithContext => 'LLM + Context',
|
||||||
|
PredictionSource::Ewma => 'EWMA',
|
||||||
|
})
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
->color(fn (PredictionSource $state) => match ($state) {
|
||||||
PredictionSource::Llm => 'success',
|
PredictionSource::Llm => 'success',
|
||||||
|
PredictionSource::LlmWithContext => 'warning',
|
||||||
PredictionSource::Ewma => 'info',
|
PredictionSource::Ewma => 'info',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('direction')
|
TextColumn::make('direction')
|
||||||
@@ -66,6 +71,7 @@ class OilPredictionResource extends Resource
|
|||||||
SelectFilter::make('source')
|
SelectFilter::make('source')
|
||||||
->options([
|
->options([
|
||||||
PredictionSource::Llm->value => 'LLM',
|
PredictionSource::Llm->value => 'LLM',
|
||||||
|
PredictionSource::LlmWithContext->value => 'LLM + Context',
|
||||||
PredictionSource::Ewma->value => 'EWMA',
|
PredictionSource::Ewma->value => 'EWMA',
|
||||||
]),
|
]),
|
||||||
SelectFilter::make('direction')
|
SelectFilter::make('direction')
|
||||||
@@ -97,9 +103,14 @@ class OilPredictionResource extends Resource
|
|||||||
TextEntry::make('predicted_for')->date('d M Y'),
|
TextEntry::make('predicted_for')->date('d M Y'),
|
||||||
TextEntry::make('source')
|
TextEntry::make('source')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
|
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
||||||
|
PredictionSource::Llm => 'LLM',
|
||||||
|
PredictionSource::LlmWithContext => 'LLM + Context',
|
||||||
|
PredictionSource::Ewma => 'EWMA',
|
||||||
|
})
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
->color(fn (PredictionSource $state) => match ($state) {
|
||||||
PredictionSource::Llm => 'success',
|
PredictionSource::Llm => 'success',
|
||||||
|
PredictionSource::LlmWithContext => 'warning',
|
||||||
PredictionSource::Ewma => 'info',
|
PredictionSource::Ewma => 'info',
|
||||||
}),
|
}),
|
||||||
TextEntry::make('direction')
|
TextEntry::make('direction')
|
||||||
|
|||||||
26
app/Http/Controllers/Api/PredictionController.php
Normal file
26
app/Http/Controllers/Api/PredictionController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\PredictionRequest;
|
||||||
|
use App\Services\NationalFuelPredictionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PredictionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NationalFuelPredictionService $predictionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(PredictionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$fuelType = $request->fuelType();
|
||||||
|
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
|
||||||
|
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
|
||||||
|
|
||||||
|
$result = $this->predictionService->predict($fuelType, $lat, $lng);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Http/Controllers/Api/StationController.php
Normal file
72
app/Http/Controllers/Api/StationController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\NearbyStationsRequest;
|
||||||
|
use App\Http\Resources\Api\StationResource;
|
||||||
|
use App\Models\Search;
|
||||||
|
use App\Models\Station;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class StationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(NearbyStationsRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$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(MAX(-1.0, MIN(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();
|
||||||
|
|
||||||
|
$stations = $all
|
||||||
|
->filter(fn ($s) => (float) $s->distance_km <= $radius)
|
||||||
|
->sortBy($sort === 'price' ? 'price_pence' : 'distance_km')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$prices = $stations->pluck('price_pence');
|
||||||
|
|
||||||
|
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,
|
||||||
|
'lowest_pence' => $prices->min(),
|
||||||
|
'highest_pence' => $prices->max(),
|
||||||
|
'cheapest_price_pence' => $prices->min(),
|
||||||
|
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/Api/StatsController.php
Normal file
49
app/Http/Controllers/Api/StatsController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Search;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class StatsController extends Controller
|
||||||
|
{
|
||||||
|
public function searches(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$period = $request->input('period', 'week');
|
||||||
|
$days = $period === 'month' ? 30 : 7;
|
||||||
|
|
||||||
|
$stats = Search::query()
|
||||||
|
->where('searched_at', '>=', now()->subDays($days))
|
||||||
|
->selectRaw('
|
||||||
|
COUNT(*) as total_searches,
|
||||||
|
COUNT(DISTINCT ip_hash) as unique_searchers,
|
||||||
|
AVG(results_count) as avg_results,
|
||||||
|
AVG(lowest_pence) as avg_lowest_pence,
|
||||||
|
AVG(highest_pence) as avg_highest_pence,
|
||||||
|
AVG(avg_pence) as avg_avg_pence
|
||||||
|
')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$totalSearches = (int) $stats->total_searches;
|
||||||
|
$uniqueSearchers = (int) $stats->unique_searchers;
|
||||||
|
$avgResults = $stats->avg_results !== null ? round((float) $stats->avg_results, 1) : 0.0;
|
||||||
|
$avgLowestPrice = $stats->avg_lowest_pence !== null ? round((float) $stats->avg_lowest_pence / 100, 1) : 0.0;
|
||||||
|
$avgHighestPrice = $stats->avg_highest_pence !== null ? round((float) $stats->avg_highest_pence / 100, 1) : 0.0;
|
||||||
|
$avgPrice = $stats->avg_avg_pence !== null ? round((float) $stats->avg_avg_pence / 100, 1) : 0.0;
|
||||||
|
|
||||||
|
$periodLabel = $period === 'month' ? 'month' : 'week';
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'total_searches' => $totalSearches,
|
||||||
|
'unique_searchers' => $uniqueSearchers,
|
||||||
|
'avg_results' => $avgResults,
|
||||||
|
'avg_lowest_price' => $avgLowestPrice,
|
||||||
|
'avg_highest_price' => $avgHighestPrice,
|
||||||
|
'avg_price' => $avgPrice,
|
||||||
|
'period' => $periodLabel,
|
||||||
|
'message' => "Helped {$uniqueSearchers} drivers find cheaper fuel this {$periodLabel} so far!",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Requests/Api/NearbyStationsRequest.php
Normal file
41
app/Http/Requests/Api/NearbyStationsRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class NearbyStationsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||||
|
'lng' => ['required', 'numeric', 'between:-180,180'],
|
||||||
|
'fuel_type' => ['required', 'string'],
|
||||||
|
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
|
||||||
|
'sort' => ['nullable', 'string', 'in:price,distance'],
|
||||||
|
'pricing_mode' => ['nullable', 'string', 'in:pump'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fuelType(): FuelType
|
||||||
|
{
|
||||||
|
return FuelType::fromAlias($this->string('fuel_type')->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function radius(): float
|
||||||
|
{
|
||||||
|
return (float) $this->input('radius', 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sort(): string
|
||||||
|
{
|
||||||
|
return $this->input('sort', 'price');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Api/PredictionRequest.php
Normal file
33
app/Http/Requests/Api/PredictionRequest.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class PredictionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fuel_type' => ['required', 'string'],
|
||||||
|
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||||
|
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fuelType(): FuelType
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return FuelType::fromAlias($this->string('fuel_type')->toString());
|
||||||
|
} catch (\ValueError) {
|
||||||
|
throw ValidationException::withMessages(['fuel_type' => 'Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Resources/Api/StationResource.php
Normal file
31
app/Http/Resources/Api/StationResource.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class StationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'station_id' => $this->node_id,
|
||||||
|
'name' => $this->trading_name,
|
||||||
|
'brand' => $this->brand_name,
|
||||||
|
'is_supermarket' => (bool) $this->is_supermarket,
|
||||||
|
'address' => implode(', ', array_filter([$this->address_line_1, $this->city])),
|
||||||
|
'postcode' => $this->postcode,
|
||||||
|
'lat' => (float) $this->lat,
|
||||||
|
'lng' => (float) $this->lng,
|
||||||
|
'distance_km' => round((float) $this->distance_km, 2),
|
||||||
|
'fuel_type' => $this->fuel_type,
|
||||||
|
'price_pence' => (int) $this->price_pence,
|
||||||
|
'price' => round((int) $this->price_pence / 100, 2),
|
||||||
|
'price_updated_at' => $this->price_effective_at
|
||||||
|
? Carbon::parse($this->price_effective_at)->toISOString()
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Models/Search.php
Normal file
24
app/Models/Search.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\SearchFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||||
|
class Search extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<SearchFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'searched_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
489
app/Services/NationalFuelPredictionService.php
Normal file
489
app/Services/NationalFuelPredictionService.php
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\StationPriceCurrent;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class NationalFuelPredictionService
|
||||||
|
{
|
||||||
|
private const float R_SQUARED_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
|
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* fuel_type: string,
|
||||||
|
* current_avg: float,
|
||||||
|
* predicted_direction: string,
|
||||||
|
* predicted_change_pence: float,
|
||||||
|
* confidence_score: float,
|
||||||
|
* confidence_label: string,
|
||||||
|
* action: string,
|
||||||
|
* reasoning: string,
|
||||||
|
* prediction_horizon_days: int,
|
||||||
|
* region_key: string,
|
||||||
|
* methodology: string,
|
||||||
|
* signals: array
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function predict(FuelType $fuelType, ?float $lat = null, ?float $lng = null): array
|
||||||
|
{
|
||||||
|
$currentAvg = $this->getCurrentNationalAverage($fuelType);
|
||||||
|
$trend = $this->computeTrendSignal($fuelType);
|
||||||
|
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
||||||
|
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
||||||
|
$stickiness = $this->computeStickinessSignal($fuelType);
|
||||||
|
|
||||||
|
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||||
|
$regionalMomentum = $lat !== null && $lng !== null
|
||||||
|
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
||||||
|
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
||||||
|
|
||||||
|
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
|
||||||
|
|
||||||
|
[$direction, $confidenceScore] = $this->aggregateSignals($signals);
|
||||||
|
|
||||||
|
$slope = $trend['slope'] ?? 0.0;
|
||||||
|
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
|
||||||
|
|
||||||
|
$confidenceLabel = match (true) {
|
||||||
|
$confidenceScore >= 70 => 'high',
|
||||||
|
$confidenceScore >= 40 => 'medium',
|
||||||
|
default => 'low',
|
||||||
|
};
|
||||||
|
|
||||||
|
$action = match ($direction) {
|
||||||
|
'up' => 'fill_now',
|
||||||
|
'down' => 'wait',
|
||||||
|
default => 'no_signal',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'fuel_type' => $fuelType->value,
|
||||||
|
'current_avg' => $currentAvg,
|
||||||
|
'predicted_direction' => $direction,
|
||||||
|
'predicted_change_pence' => $predictedChangePence,
|
||||||
|
'confidence_score' => $confidenceScore,
|
||||||
|
'confidence_label' => $confidenceLabel,
|
||||||
|
'action' => $action,
|
||||||
|
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
|
||||||
|
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
||||||
|
'region_key' => 'national',
|
||||||
|
'methodology' => 'multi_signal_live_fallback',
|
||||||
|
'signals' => [
|
||||||
|
'trend' => $trend,
|
||||||
|
'day_of_week' => $dayOfWeek,
|
||||||
|
'brand_behaviour' => $brandBehaviour,
|
||||||
|
'national_momentum' => $nationalMomentum,
|
||||||
|
'regional_momentum' => $regionalMomentum,
|
||||||
|
'price_stickiness' => $stickiness,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCurrentNationalAverage(FuelType $fuelType): float
|
||||||
|
{
|
||||||
|
$avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
|
||||||
|
|
||||||
|
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)->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 / 2.0) * ($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 < 56) {
|
||||||
|
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
|
||||||
|
}
|
||||||
|
|
||||||
|
$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'];
|
||||||
|
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
|
||||||
|
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
|
||||||
|
$tomorrowDelta = 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);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $uniqueDays / 90),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
|
||||||
|
'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)->values()->all())['slope'];
|
||||||
|
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v)->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)->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||||||
|
private function disabledSignal(string $detail): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.0,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => 0,
|
||||||
|
'enabled' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted aggregate of enabled signals.
|
||||||
|
* Returns [direction string, confidence score 0-100].
|
||||||
|
*
|
||||||
|
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
|
||||||
|
* @return array{0: string, 1: float}
|
||||||
|
*/
|
||||||
|
private function aggregateSignals(array $signals): array
|
||||||
|
{
|
||||||
|
$weights = [
|
||||||
|
'trend' => 0.45,
|
||||||
|
'dayOfWeek' => 0.20,
|
||||||
|
'brandBehaviour' => 0.25,
|
||||||
|
'stickiness' => 0.10,
|
||||||
|
];
|
||||||
|
|
||||||
|
$weightedSum = 0.0;
|
||||||
|
$totalWeight = 0.0;
|
||||||
|
|
||||||
|
foreach ($weights as $key => $weight) {
|
||||||
|
$signal = $signals[$key] ?? null;
|
||||||
|
if ($signal && $signal['enabled']) {
|
||||||
|
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||||
|
$totalWeight += $weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($totalWeight < 0.01) {
|
||||||
|
return ['stable', 0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = $weightedSum / $totalWeight;
|
||||||
|
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
|
||||||
|
|
||||||
|
$direction = match (true) {
|
||||||
|
$normalised >= 0.1 => 'up',
|
||||||
|
$normalised <= -0.1 => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [$direction, $confidenceScore];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($trend['enabled'] && abs($slope) >= self::SLOPE_THRESHOLD_PENCE) {
|
||||||
|
$parts[] = $trend['detail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($brandBehaviour['enabled'] && $brandBehaviour['direction'] !== 'stable') {
|
||||||
|
$parts[] = $brandBehaviour['detail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
return 'No clear pattern — fill up at the cheapest station near you now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ class OilPriceService
|
|||||||
|
|
||||||
return new PricePrediction([
|
return new PricePrediction([
|
||||||
'predicted_for' => now()->toDateString(),
|
'predicted_for' => now()->toDateString(),
|
||||||
'source' => PredictionSource::Llm,
|
'source' => PredictionSource::LlmWithContext,
|
||||||
'direction' => $direction,
|
'direction' => $direction,
|
||||||
'confidence' => $confidence,
|
'confidence' => $confidence,
|
||||||
'reasoning' => $data['reasoning'],
|
'reasoning' => $data['reasoning'],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"filament/filament": "^5.0",
|
"filament/filament": "^5.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/flux": "^2.12.0",
|
"livewire/flux": "^2.12.0",
|
||||||
"livewire/livewire": "^4.1"
|
"livewire/livewire": "^4.1"
|
||||||
|
|||||||
65
composer.lock
generated
65
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7279d4f0e10b9575237a7b483de6d09e",
|
"content-hash": "017a8badf2a8b99d8c2de9909475415f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -2468,6 +2468,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-23T14:35:33+00:00"
|
"time": "2026-03-23T14:35:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/sanctum",
|
||||||
|
"version": "v4.3.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/sanctum.git",
|
||||||
|
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||||
|
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"symfony/console": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Sanctum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"laravel",
|
||||||
|
"sanctum"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/sanctum/issues",
|
||||||
|
"source": "https://github.com/laravel/sanctum"
|
||||||
|
},
|
||||||
|
"time": "2026-02-07T17:19:31+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.10",
|
"version": "v2.0.10",
|
||||||
|
|||||||
87
config/sanctum.php
Normal file
87
config/sanctum.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
28
database/factories/SearchFactory.php
Normal file
28
database/factories/SearchFactory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Search;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/** @extends Factory<Search> */
|
||||||
|
class SearchFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$lowest = fake()->numberBetween(12000, 15000);
|
||||||
|
$highest = $lowest + fake()->numberBetween(100, 3000);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lat_bucket' => round(fake()->latitude(49.9, 60.9), 2),
|
||||||
|
'lng_bucket' => round(fake()->longitude(-8.2, 1.8), 2),
|
||||||
|
'fuel_type' => fake()->randomElement(['b7_standard', 'e10', 'e5']),
|
||||||
|
'results_count' => fake()->numberBetween(5, 100),
|
||||||
|
'lowest_pence' => $lowest,
|
||||||
|
'highest_pence' => $highest,
|
||||||
|
'avg_pence' => round(($lowest + $highest) / 2, 2),
|
||||||
|
'searched_at' => now(),
|
||||||
|
'ip_hash' => hash('sha256', fake()->ipv4()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
|||||||
Schema::create('price_predictions', function (Blueprint $table) {
|
Schema::create('price_predictions', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->date('predicted_for')->comment('The date this prediction covers');
|
$table->date('predicted_for')->comment('The date this prediction covers');
|
||||||
$table->enum('source', ['llm', 'ewma']);
|
$table->enum('source', ['llm', 'llm_with_context', 'ewma']);
|
||||||
$table->enum('direction', ['rising', 'falling', 'flat']);
|
$table->enum('direction', ['rising', 'falling', 'flat']);
|
||||||
$table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score');
|
$table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score');
|
||||||
$table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary');
|
$table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary');
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('searches', function (Blueprint $table): void {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->decimal('lat_bucket', 5, 2)->comment('Latitude rounded to 2dp (~1km precision) for privacy');
|
||||||
|
$table->decimal('lng_bucket', 5, 2)->comment('Longitude rounded to 2dp for privacy');
|
||||||
|
$table->string('fuel_type', 20);
|
||||||
|
$table->unsignedSmallInteger('results_count');
|
||||||
|
$table->unsignedSmallInteger('lowest_pence')->nullable()->comment('Cheapest price found in pence × 100');
|
||||||
|
$table->unsignedSmallInteger('highest_pence')->nullable()->comment('Most expensive price found in pence × 100');
|
||||||
|
$table->decimal('avg_pence', 8, 2)->nullable()->comment('Mean price across results in pence × 100');
|
||||||
|
$table->dateTime('searched_at');
|
||||||
|
$table->string('ip_hash', 64)->comment('SHA-256 of requester IP — non-reversible, for unique count only');
|
||||||
|
|
||||||
|
$table->index(['searched_at', 'fuel_type']);
|
||||||
|
$table->index('ip_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('searches');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->text('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
DB::statement("ALTER TABLE price_predictions MODIFY COLUMN source ENUM('llm', 'llm_with_context', 'ewma') NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
DB::statement("ALTER TABLE price_predictions MODIFY COLUMN source ENUM('llm', 'ewma') NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
20
routes/api.php
Normal file
20
routes/api.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\AuthController;
|
||||||
|
use App\Http\Controllers\Api\PredictionController;
|
||||||
|
use App\Http\Controllers\Api\StationController;
|
||||||
|
use App\Http\Controllers\Api\StatsController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/stations', [StationController::class, 'index']);
|
||||||
|
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||||
|
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||||
|
|
||||||
|
Route::prefix('auth')->group(function (): void {
|
||||||
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
Route::middleware('auth:sanctum')->group(function (): void {
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::get('/me', [AuthController::class, 'me']);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/Feature/Api/PredictionControllerTest.php
Normal file
59
tests/Feature/Api/PredictionControllerTest.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\StationPriceCurrent;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns a prediction response for diesel', function () {
|
||||||
|
$this->getJson('/api/prediction?fuel_type=diesel')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||||
|
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||||
|
'prediction_horizon_days', 'region_key', 'methodology',
|
||||||
|
'signals' => [
|
||||||
|
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertJsonPath('fuel_type', 'b7_standard')
|
||||||
|
->assertJsonPath('region_key', 'national');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes current average from live prices', function () {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14750,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/prediction?fuel_type=diesel')->assertOk();
|
||||||
|
|
||||||
|
expect($response->json('current_avg'))->toBe(147.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional lat and lng for regional context', function () {
|
||||||
|
$this->getJson('/api/prediction?fuel_type=diesel&lat=52.5&lng=-0.2')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('region_key', 'national'); // still national, regional_momentum signal updated internally
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when fuel_type is missing', function () {
|
||||||
|
$this->getJson('/api/prediction')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['fuel_type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 for unknown fuel_type alias', function () {
|
||||||
|
$this->getJson('/api/prediction?fuel_type=rocket_fuel')
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['fuel_type']);
|
||||||
|
});
|
||||||
104
tests/Feature/Api/StationControllerTest.php
Normal file
104
tests/Feature/Api/StationControllerTest.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\StationPriceCurrent;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns stations near coordinates filtered by fuel type', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10&sort=price')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [['station_id', 'name', 'brand', 'is_supermarket', 'lat', 'lng', 'distance_km', 'fuel_type', 'price_pence', 'price', 'price_updated_at']],
|
||||||
|
'meta' => ['count', 'fuel_type', 'radius_km', 'lowest_pence'],
|
||||||
|
])
|
||||||
|
->assertJsonPath('data.0.price_pence', 14500)
|
||||||
|
->assertJsonPath('meta.fuel_type', 'b7_standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes stations with no matching fuel type', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::E10, // not diesel
|
||||||
|
'price_pence' => 13800,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.count', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes temporarily closed stations', function () {
|
||||||
|
$closed = Station::factory()->create([
|
||||||
|
'lat' => 52.555064, 'lng' => -0.256119,
|
||||||
|
'temporary_closure' => true,
|
||||||
|
]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $closed->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.count', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes stations beyond radius', function () {
|
||||||
|
// Station ~100km north
|
||||||
|
$farStation = Station::factory()->create(['lat' => 53.5, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $farStation->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.count', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by price when sort=price', function () {
|
||||||
|
$sLat = 52.555;
|
||||||
|
$sLng = -0.256;
|
||||||
|
$cheap = Station::factory()->create(['lat' => $sLat, 'lng' => $sLng]);
|
||||||
|
$expensive = Station::factory()->create(['lat' => $sLat + 0.001, 'lng' => $sLng]);
|
||||||
|
|
||||||
|
StationPriceCurrent::factory()->create(['station_id' => $cheap->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 13900]);
|
||||||
|
StationPriceCurrent::factory()->create(['station_id' => $expensive->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
|
||||||
|
|
||||||
|
$this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=diesel&radius=10&sort=price")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.price_pence', 13900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a search record for each request', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('searches', [
|
||||||
|
'lat_bucket' => '52.56',
|
||||||
|
'lng_bucket' => '-0.26',
|
||||||
|
'fuel_type' => 'b7_standard',
|
||||||
|
'results_count' => 1,
|
||||||
|
'lowest_pence' => 14500,
|
||||||
|
'highest_pence' => 14500,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when required params are missing', function () {
|
||||||
|
$this->getJson('/api/stations?lat=52.5')
|
||||||
|
->assertUnprocessable();
|
||||||
|
});
|
||||||
64
tests/Feature/Api/StatsControllerTest.php
Normal file
64
tests/Feature/Api/StatsControllerTest.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Search;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns search stats for current week', function () {
|
||||||
|
// 10 searches within the rolling 7 days (3 unique IPs)
|
||||||
|
Search::factory()->count(5)->create([
|
||||||
|
'searched_at' => now()->subDays(2),
|
||||||
|
'ip_hash' => hash('sha256', '1.2.3.4'),
|
||||||
|
'lowest_pence' => 13800,
|
||||||
|
'highest_pence' => 14500,
|
||||||
|
'avg_pence' => 14150.00,
|
||||||
|
'results_count' => 20,
|
||||||
|
]);
|
||||||
|
Search::factory()->count(3)->create([
|
||||||
|
'searched_at' => now()->subDays(4),
|
||||||
|
'ip_hash' => hash('sha256', '5.6.7.8'),
|
||||||
|
'lowest_pence' => 14200,
|
||||||
|
'highest_pence' => 15000,
|
||||||
|
'avg_pence' => 14600.00,
|
||||||
|
'results_count' => 30,
|
||||||
|
]);
|
||||||
|
Search::factory()->count(2)->create([
|
||||||
|
'searched_at' => now()->subDays(6),
|
||||||
|
'ip_hash' => hash('sha256', '9.10.11.12'),
|
||||||
|
'lowest_pence' => 13500,
|
||||||
|
'highest_pence' => 14000,
|
||||||
|
'avg_pence' => 13750.00,
|
||||||
|
'results_count' => 10,
|
||||||
|
]);
|
||||||
|
// 5 searches outside the 7-day window
|
||||||
|
Search::factory()->count(5)->create(['searched_at' => now()->subDays(10)]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stats/searches?period=week')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['total_searches', 'unique_searchers', 'avg_results', 'avg_lowest_price', 'avg_highest_price', 'avg_price', 'period', 'message'])
|
||||||
|
->assertJsonPath('total_searches', 10)
|
||||||
|
->assertJsonPath('unique_searchers', 3)
|
||||||
|
->assertJsonPath('period', 'week');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a human readable message', function () {
|
||||||
|
Search::factory()->count(3)->create(['searched_at' => now()->subDay()]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/stats/searches?period=week')->assertOk();
|
||||||
|
|
||||||
|
expect($response->json('message'))->toContain('drivers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero stats when no searches exist', function () {
|
||||||
|
$this->getJson('/api/stats/searches?period=week')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('total_searches', 0)
|
||||||
|
->assertJsonPath('unique_searchers', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to week period when period param is omitted', function () {
|
||||||
|
$this->getJson('/api/stats/searches')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('period', 'week');
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ use Tests\TestCase;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
pest()->extend(TestCase::class)
|
pest()->extend(TestCase::class)
|
||||||
// ->use(RefreshDatabase::class)
|
->use(RefreshDatabase::class)
|
||||||
->in('Feature', 'Unit');
|
->in('Feature', 'Unit');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
28
tests/Unit/Enums/FuelTypeTest.php
Normal file
28
tests/Unit/Enums/FuelTypeTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
|
||||||
|
it('resolves diesel alias to B7Standard', function () {
|
||||||
|
expect(FuelType::fromAlias('diesel'))->toBe(FuelType::B7Standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves petrol alias to E10', function () {
|
||||||
|
expect(FuelType::fromAlias('petrol'))->toBe(FuelType::E10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves unleaded alias to E10', function () {
|
||||||
|
expect(FuelType::fromAlias('unleaded'))->toBe(FuelType::E10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves premium_unleaded alias to E5', function () {
|
||||||
|
expect(FuelType::fromAlias('premium_unleaded'))->toBe(FuelType::E5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts canonical enum values as aliases', function () {
|
||||||
|
expect(FuelType::fromAlias('e10'))->toBe(FuelType::E10);
|
||||||
|
expect(FuelType::fromAlias('b7_standard'))->toBe(FuelType::B7Standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValueError for unknown alias', function () {
|
||||||
|
FuelType::fromAlias('avgas');
|
||||||
|
})->throws(ValueError::class);
|
||||||
107
tests/Unit/Services/NationalFuelPredictionServiceTest.php
Normal file
107
tests/Unit/Services/NationalFuelPredictionServiceTest.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\StationPrice;
|
||||||
|
use App\Models\StationPriceCurrent;
|
||||||
|
use App\Services\NationalFuelPredictionService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns no_signal when there is insufficient price history', function () {
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
expect($result['predicted_direction'])->toBe('stable')
|
||||||
|
->and($result['signals']['trend']['enabled'])->toBeFalse()
|
||||||
|
->and($result['action'])->toBe('no_signal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects rising trend from consistently increasing daily averages', function () {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
// 7 days of prices rising at ~100 pence/day
|
||||||
|
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14000 + ((6 - $daysAgo) * 100),
|
||||||
|
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
expect($result['signals']['trend']['direction'])->toBe('up')
|
||||||
|
->and($result['signals']['trend']['enabled'])->toBeTrue()
|
||||||
|
->and($result['predicted_direction'])->toBe('up')
|
||||||
|
->and($result['action'])->toBe('fill_now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects falling trend from consistently decreasing daily averages', function () {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 16000 - ((6 - $daysAgo) * 100),
|
||||||
|
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
expect($result['signals']['trend']['direction'])->toBe('down')
|
||||||
|
->and($result['predicted_direction'])->toBe('down')
|
||||||
|
->and($result['action'])->toBe('wait');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns current_avg from station_prices_current', function () {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14750,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
expect($result['current_avg'])->toBe(147.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all required keys in response', function () {
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys([
|
||||||
|
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||||
|
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||||
|
'prediction_horizon_days', 'region_key', 'methodology',
|
||||||
|
'signals',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['signals'])->toHaveKeys([
|
||||||
|
'trend', 'day_of_week', 'brand_behaviour',
|
||||||
|
'national_momentum', 'regional_momentum', 'price_stickiness',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables trend signal when r_squared is below 0.5', function () {
|
||||||
|
$station = Station::factory()->create();
|
||||||
|
|
||||||
|
// Highly erratic prices (zigzag pattern) — low R²
|
||||||
|
$prices = [14000, 16000, 13000, 17000, 12000, 18000, 14500];
|
||||||
|
foreach ($prices as $daysAgo => $price) {
|
||||||
|
StationPrice::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => $price,
|
||||||
|
'price_effective_at' => now()->subDays(count($prices) - 1 - $daysAgo)->setTime(12, 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
|
||||||
|
|
||||||
|
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
|
||||||
|
expect($result['signals']['trend']['data_points'])->toBeInt();
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Http;
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
$this->service = new OilPriceService(new ApiLogger);
|
$this->service = new OilPriceService(new ApiLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ it('filters out FRED missing value markers', function (): void {
|
|||||||
'*/fred/series/observations*' => Http::response([
|
'*/fred/series/observations*' => Http::response([
|
||||||
'observations' => [
|
'observations' => [
|
||||||
['date' => '2026-04-01', 'value' => '75.10'],
|
['date' => '2026-04-01', 'value' => '75.10'],
|
||||||
['date' => '2026-04-02', 'value' => '.'], // weekend/holiday
|
['date' => '2026-04-02', 'value' => '.'],
|
||||||
['date' => '2026-04-03', 'value' => '74.20'],
|
['date' => '2026-04-03', 'value' => '74.20'],
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
@@ -105,7 +106,7 @@ it('returns flat when price movement is within threshold', function (): void {
|
|||||||
->and($prediction->confidence)->toBe(50);
|
->and($prediction->confidence)->toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when fewer than 14 prices are available', function (): void {
|
it('returns null when fewer than 14 prices are available for EWMA', function (): void {
|
||||||
$prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([
|
$prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([
|
||||||
'date' => now()->subDays(10 - $i)->toDateString(),
|
'date' => now()->subDays(10 - $i)->toDateString(),
|
||||||
'price_usd' => 75.0,
|
'price_usd' => 75.0,
|
||||||
@@ -172,9 +173,113 @@ it('returns null when LLM returns malformed JSON', function (): void {
|
|||||||
expect($this->service->generateLlmPrediction($prices))->toBeNull();
|
expect($this->service->generateLlmPrediction($prices))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- generateLlmPredictionWithContext ---
|
||||||
|
|
||||||
|
it('generates LLM prediction with context and returns LlmWithContext source', function (): void {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
|
||||||
|
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||||
|
'price_usd' => 80.0 + $i * 0.5,
|
||||||
|
]));
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":72,"reasoning":"OPEC+ extended cuts while prices trend upward."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prediction = $this->service->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
expect($prediction)->not->toBeNull()
|
||||||
|
->and($prediction->direction)->toBe(TrendDirection::Rising)
|
||||||
|
->and($prediction->confidence)->toBe(72)
|
||||||
|
->and($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
||||||
|
->and($prediction->reasoning)->toBe('OPEC+ extended cuts while prices trend upward.');
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends web_search tool in the context prediction request', function (): void {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
|
||||||
|
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$tools = $request->data()['tools'] ?? [];
|
||||||
|
|
||||||
|
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include EWMA indicators in the context prediction prompt', function (): void {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
|
||||||
|
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$content = $request->data()['messages'][0]['content'] ?? '';
|
||||||
|
|
||||||
|
return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues on pause_turn and returns final answer', function (): void {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
|
||||||
|
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'content' => [['type' => 'server_tool_use', 'id' => 'sttool_1', 'name' => 'web_search', 'input' => ['query' => 'Brent crude news']]],
|
||||||
|
'stop_reason' => 'pause_turn',
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prediction = $this->service->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
expect($prediction)->not->toBeNull()
|
||||||
|
->and($prediction->direction)->toBe(TrendDirection::Falling);
|
||||||
|
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
// --- generatePrediction (orchestrator) ---
|
// --- generatePrediction (orchestrator) ---
|
||||||
|
|
||||||
it('uses LLM when API key is configured', function (): void {
|
it('uses LLM with context when API key is configured', function (): void {
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
BrentPrice::insert(
|
BrentPrice::insert(
|
||||||
@@ -186,9 +291,32 @@ it('uses LLM when API key is configured', function (): void {
|
|||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'https://api.anthropic.com/*' => Http::response([
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
'content' => [
|
'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}']],
|
||||||
['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}'],
|
'stop_reason' => 'end_turn',
|
||||||
],
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prediction = $this->service->generatePrediction();
|
||||||
|
|
||||||
|
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
||||||
|
->and(PricePrediction::count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain LLM when context method fails', function (): void {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::insert(
|
||||||
|
collect(range(1, 20))->map(fn (int $i) => [
|
||||||
|
'date' => now()->subDays(20 - $i)->toDateString(),
|
||||||
|
'price_usd' => 75.0 + ($i * 0.8),
|
||||||
|
])->all()
|
||||||
|
);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::sequence()
|
||||||
|
->push([], 500)
|
||||||
|
->push([
|
||||||
|
'content' => [['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend up."}']],
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -198,7 +326,7 @@ it('uses LLM when API key is configured', function (): void {
|
|||||||
->and(PricePrediction::count())->toBe(1);
|
->and(PricePrediction::count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to EWMA when LLM fails', function (): void {
|
it('falls back to EWMA when both LLM methods fail', function (): void {
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
BrentPrice::insert(
|
BrentPrice::insert(
|
||||||
@@ -227,131 +355,3 @@ it('returns null when there is insufficient price data', function (): void {
|
|||||||
expect($this->service->generatePrediction())->toBeNull()
|
expect($this->service->generatePrediction())->toBeNull()
|
||||||
->and(PricePrediction::count())->toBe(0);
|
->and(PricePrediction::count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- generateLlmPredictionWithContext ---
|
|
||||||
|
|
||||||
it('generates llm prediction with context using web search and raw prices', function () {
|
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
|
||||||
|
|
||||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
|
||||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
|
||||||
'price_usd' => 80.0 + $s->index * 0.5,
|
|
||||||
])->create();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://api.anthropic.com/*' => Http::response([
|
|
||||||
'content' => [
|
|
||||||
['type' => 'text', 'text' => '{"direction":"rising","confidence":72,"reasoning":"OPEC+ extended cuts while prices trend upward."}'],
|
|
||||||
],
|
|
||||||
'stop_reason' => 'end_turn',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
|
||||||
|
|
||||||
expect($prediction)->not->toBeNull()
|
|
||||||
->and($prediction->direction)->toBe(TrendDirection::Rising)
|
|
||||||
->and($prediction->confidence)->toBe(72)
|
|
||||||
->and($prediction->source)->toBe(PredictionSource::Llm)
|
|
||||||
->and($prediction->reasoning)->toBe('OPEC+ extended cuts while prices trend upward.');
|
|
||||||
|
|
||||||
Http::assertSentCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends web_search tool in the context prediction request', function () {
|
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
|
||||||
|
|
||||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
|
||||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
|
||||||
'price_usd' => 80.0,
|
|
||||||
])->create();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://api.anthropic.com/*' => Http::response([
|
|
||||||
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
|
||||||
'stop_reason' => 'end_turn',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
|
||||||
$tools = $request->data()['tools'] ?? [];
|
|
||||||
|
|
||||||
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not include ewma indicators in the context prediction request', function () {
|
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
|
||||||
|
|
||||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
|
||||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
|
||||||
'price_usd' => 80.0,
|
|
||||||
])->create();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://api.anthropic.com/*' => Http::response([
|
|
||||||
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
|
||||||
'stop_reason' => 'end_turn',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
|
||||||
$content = $request->data()['messages'][0]['content'] ?? '';
|
|
||||||
|
|
||||||
return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('context prediction continues on pause_turn and returns final answer', function () {
|
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
|
||||||
|
|
||||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
|
||||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
|
||||||
'price_usd' => 80.0,
|
|
||||||
])->create();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://api.anthropic.com/*' => Http::sequence()
|
|
||||||
->push([
|
|
||||||
'content' => [['type' => 'server_tool_use', 'id' => 'sttool_1', 'name' => 'web_search', 'input' => ['query' => 'Brent crude news']]],
|
|
||||||
'stop_reason' => 'pause_turn',
|
|
||||||
], 200)
|
|
||||||
->push([
|
|
||||||
'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']],
|
|
||||||
'stop_reason' => 'end_turn',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
|
||||||
|
|
||||||
expect($prediction)->not->toBeNull()
|
|
||||||
->and($prediction->direction)->toBe(TrendDirection::Falling);
|
|
||||||
|
|
||||||
Http::assertSentCount(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generatePrediction falls through to ewma when both llm methods fail', function () {
|
|
||||||
config(['services.anthropic.api_key' => 'test-key']);
|
|
||||||
|
|
||||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
|
||||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
|
||||||
'price_usd' => 80.0,
|
|
||||||
])->create();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://api.anthropic.com/*' => Http::response([], 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$prediction = app(OilPriceService::class)->generatePrediction();
|
|
||||||
|
|
||||||
expect($prediction)->not->toBeNull()
|
|
||||||
->and($prediction->source)->toBe(PredictionSource::Ewma);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user