- New method uses web_search_20260209 server-side tool so Claude fetches 48h of oil/geopolitical news autonomously before predicting direction - Prompt uses raw prices only — no pre-computed EWMA indicators - pause_turn loop handles server-side search continuation (up to 5 iters) - generatePrediction() now tries context method first, falls back to generateLlmPrediction(), then EWMA - Default model updated to claude-sonnet-4-6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
14 KiB
PHP
380 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\PredictionSource;
|
||
use App\Enums\TrendDirection;
|
||
use App\Models\BrentPrice;
|
||
use App\Models\PricePrediction;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Throwable;
|
||
|
||
class OilPriceService
|
||
{
|
||
/**
|
||
* Decay factor for EWMA. Higher = more weight on recent prices.
|
||
*/
|
||
private const float EWMA_ALPHA = 0.3;
|
||
|
||
/**
|
||
* Minimum % change in EWMA to be considered rising/falling.
|
||
*/
|
||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||
|
||
/**
|
||
* EWMA confidence is capped lower than LLM — it's a simpler model.
|
||
*/
|
||
private const int EWMA_MAX_CONFIDENCE = 65;
|
||
|
||
/**
|
||
* LLM confidence is capped — no model should be certain about oil prices.
|
||
*/
|
||
private const int LLM_MAX_CONFIDENCE = 85;
|
||
|
||
/**
|
||
* Minimum price rows needed before EWMA is meaningful.
|
||
*/
|
||
private const int EWMA_MIN_ROWS = 14;
|
||
|
||
public function __construct(
|
||
private readonly ApiLogger $apiLogger,
|
||
) {}
|
||
|
||
/**
|
||
* Fetch the last 30 days of Brent crude prices from FRED and store them.
|
||
*/
|
||
public function fetchBrentPrices(): void
|
||
{
|
||
$url = 'https://api.stlouisfed.org/fred/series/observations';
|
||
|
||
try {
|
||
$response = $this->apiLogger->send('fred', 'GET', $url, fn () => Http::timeout(10)
|
||
->get($url, [
|
||
'series_id' => 'DCOILBRENTEU',
|
||
'api_key' => config('services.fred.api_key'),
|
||
'sort_order' => 'desc',
|
||
'limit' => 30,
|
||
'file_type' => 'json',
|
||
]));
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]);
|
||
|
||
return;
|
||
}
|
||
|
||
$rows = collect($response->json('observations') ?? [])
|
||
->filter(fn (array $obs) => $obs['value'] !== '.') // FRED uses '.' for missing data
|
||
->map(fn (array $obs) => [
|
||
'date' => $obs['date'],
|
||
'price_usd' => (float) $obs['value'],
|
||
])
|
||
->all();
|
||
|
||
if (empty($rows)) {
|
||
Log::warning('OilPriceService: no valid FRED observations returned');
|
||
|
||
return;
|
||
}
|
||
|
||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||
} catch (Throwable $e) {
|
||
Log::error('OilPriceService: fetchBrentPrices failed', ['error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate a prediction using LLM first, falling back to EWMA.
|
||
* Stores the result in price_predictions and returns it.
|
||
*/
|
||
public function generatePrediction(): ?PricePrediction
|
||
{
|
||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||
|
||
if ($prices->count() < self::EWMA_MIN_ROWS) {
|
||
Log::warning('OilPriceService: not enough price data to generate prediction', [
|
||
'rows' => $prices->count(),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$prediction = null;
|
||
|
||
if (config('services.anthropic.api_key')) {
|
||
$prediction = $this->generateLlmPredictionWithContext($prices);
|
||
$prediction ??= $this->generateLlmPrediction($prices);
|
||
}
|
||
|
||
$prediction ??= $this->generateEwmaPrediction($prices);
|
||
|
||
if ($prediction !== null) {
|
||
PricePrediction::create($prediction->toArray());
|
||
}
|
||
|
||
return $prediction;
|
||
}
|
||
|
||
/**
|
||
* Option B — LLM prediction via Anthropic API.
|
||
* Sends recent prices + pre-computed EWMA context and asks for direction + confidence.
|
||
*/
|
||
public function generateLlmPrediction(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 = $chronological
|
||
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
|
||
->implode("\n");
|
||
|
||
$prompt = <<<PROMPT
|
||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||
Your goal is to 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}
|
||
|
||
Respond with JSON only, no other text:
|
||
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence"}
|
||
PROMPT;
|
||
|
||
$url = 'https://api.anthropic.com/v1/messages';
|
||
|
||
try {
|
||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||
->withHeaders([
|
||
'x-api-key' => config('services.anthropic.api_key'),
|
||
'anthropic-version' => '2023-06-01',
|
||
])
|
||
->post($url, [
|
||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||
'max_tokens' => 256,
|
||
'messages' => [
|
||
['role' => 'user', 'content' => $prompt],
|
||
],
|
||
]));
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('OilPriceService: Anthropic request failed', ['status' => $response->status()]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$text = $response->json('content.0.text') ?? '';
|
||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||
$text = preg_replace('/```\s*$/m', '', $text);
|
||
$data = json_decode(trim($text), true);
|
||
|
||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$direction = TrendDirection::tryFrom($data['direction']);
|
||
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
|
||
|
||
if ($direction === null) {
|
||
Log::error('OilPriceService: invalid direction in LLM response', ['direction' => $data['direction']]);
|
||
|
||
return null;
|
||
}
|
||
|
||
return new PricePrediction([
|
||
'predicted_for' => now()->toDateString(),
|
||
'source' => PredictionSource::Llm,
|
||
'direction' => $direction,
|
||
'confidence' => $confidence,
|
||
'reasoning' => $data['reasoning'],
|
||
'generated_at' => now(),
|
||
]);
|
||
} catch (Throwable $e) {
|
||
Log::error('OilPriceService: generateLlmPrediction failed', ['error' => $e->getMessage()]);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* LLM prediction with 48h geopolitical context via Anthropic web search.
|
||
* Claude searches for recent oil/geopolitical news before answering.
|
||
* Reasons from raw prices only — no pre-computed indicators in prompt.
|
||
*/
|
||
public function generateLlmPredictionWithContext(Collection $prices): ?PricePrediction
|
||
{
|
||
$priceList = $prices->sortBy('date')
|
||
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
|
||
->implode("\n");
|
||
|
||
$prompt = <<<PROMPT
|
||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||
Your goal is to predict the short-term direction over the next 3–5 days.
|
||
|
||
First, search for recent news (last 48 hours) about:
|
||
- Brent crude oil price movements
|
||
- OPEC+ production decisions or announcements
|
||
- Major geopolitical events affecting oil supply (Middle East, Russia, US sanctions)
|
||
- Global demand signals (China economic data, US inventory reports)
|
||
|
||
Then, combining the news context with the price history below, predict the direction.
|
||
|
||
Recent Brent crude prices (USD/barrel):
|
||
{$priceList}
|
||
|
||
Respond with JSON only, no other text:
|
||
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence combining price trend and key news factor"}
|
||
PROMPT;
|
||
|
||
$url = 'https://api.anthropic.com/v1/messages';
|
||
$messages = [['role' => 'user', 'content' => $prompt]];
|
||
$response = null;
|
||
|
||
try {
|
||
for ($i = 0; $i < 5; $i++) {
|
||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||
->withHeaders([
|
||
'x-api-key' => config('services.anthropic.api_key'),
|
||
'anthropic-version' => '2023-06-01',
|
||
])
|
||
->post($url, [
|
||
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
|
||
'max_tokens' => 1024,
|
||
'tools' => [['type' => 'web_search_20260209', 'name' => 'web_search']],
|
||
'messages' => $messages,
|
||
]));
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]);
|
||
|
||
return null;
|
||
}
|
||
|
||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||
break;
|
||
}
|
||
|
||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||
}
|
||
|
||
$text = collect($response->json('content') ?? [])
|
||
->firstWhere('type', 'text')['text'] ?? '';
|
||
|
||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||
$text = preg_replace('/```\s*$/m', '', $text);
|
||
$data = json_decode(trim($text), true);
|
||
|
||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||
Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$direction = TrendDirection::tryFrom($data['direction']);
|
||
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
|
||
|
||
if ($direction === null) {
|
||
Log::error('OilPriceService: invalid direction in context LLM response', ['direction' => $data['direction']]);
|
||
|
||
return null;
|
||
}
|
||
|
||
return new PricePrediction([
|
||
'predicted_for' => now()->toDateString(),
|
||
'source' => PredictionSource::Llm,
|
||
'direction' => $direction,
|
||
'confidence' => $confidence,
|
||
'reasoning' => $data['reasoning'],
|
||
'generated_at' => now(),
|
||
]);
|
||
} catch (Throwable $e) {
|
||
Log::error('OilPriceService: generateLlmPredictionWithContext failed', ['error' => $e->getMessage()]);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
||
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
||
*/
|
||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
||
{
|
||
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
|
||
|
||
if (count($chronological) < self::EWMA_MIN_ROWS) {
|
||
return null;
|
||
}
|
||
|
||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
||
|
||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
||
|
||
[$direction, $confidence] = match (true) {
|
||
$changePct >= self::EWMA_THRESHOLD_PCT => [
|
||
TrendDirection::Rising,
|
||
$this->ewmaConfidence($changePct),
|
||
],
|
||
$changePct <= -self::EWMA_THRESHOLD_PCT => [
|
||
TrendDirection::Falling,
|
||
$this->ewmaConfidence(abs($changePct)),
|
||
],
|
||
default => [TrendDirection::Flat, 50],
|
||
};
|
||
|
||
$reasoning = sprintf(
|
||
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
|
||
$ewma3,
|
||
$ewma7,
|
||
abs($changePct),
|
||
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
|
||
);
|
||
|
||
return new PricePrediction([
|
||
'predicted_for' => now()->toDateString(),
|
||
'source' => PredictionSource::Ewma,
|
||
'direction' => $direction,
|
||
'confidence' => $confidence,
|
||
'reasoning' => $reasoning,
|
||
'generated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Compute Exponential Weighted Moving Average for a series of prices.
|
||
*
|
||
* @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);
|
||
}
|
||
|
||
/**
|
||
* Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score.
|
||
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
||
*/
|
||
private function ewmaConfidence(float $changePct): int
|
||
{
|
||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||
|
||
return (int) round(max(30, $scaled));
|
||
}
|
||
}
|