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 = <<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 = << '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::LlmWithContext, '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)); } }