3% (FRED `DCOILBRENTEU`). * 2. Most recent `llm_overlays.major_impact_event = true` AND at * least one verified URL. * 3. `station_prices` daily churn > 1.5× 30-day baseline. Gated * until ≥ 180 days of polling — toggleable via config. * 4. `watched_events` row covering today. * * When the flag flips ON, an event-driven LLM refresh is queued * (Layer 4 enforces its own 4h cooldown). When OFF, the row is * closed with `flipped_off_at`. */ final class VolatilityRegimeService { private const float BRENT_MOVE_PCT = 3.0; private const float STATION_CHURN_RATIO = 1.5; private const int STATION_CHURN_MIN_POLLING_DAYS = 180; public function __construct( private readonly LlmOverlayService $llmOverlay, ) {} public function evaluate(): ?VolatilityRegime { $trigger = $this->detectTrigger(); $current = VolatilityRegime::currentlyActive(); if ($trigger !== null && $current === null) { $row = $this->flipOn($trigger); $this->llmOverlay->run(eventDriven: true); return $row; } if ($trigger === null && $current !== null) { $this->flipOff($current); return null; } return $current; } /** @return array{type: string, detail: string}|null */ private function detectTrigger(): ?array { return $this->brentMoveTrigger() ?? $this->llmEventTrigger() ?? $this->stationChurnTrigger() ?? $this->watchedEventTrigger(); } /** @return array{type: string, detail: string}|null */ private function brentMoveTrigger(): ?array { $rows = BrentPrice::query() ->orderByDesc('date') ->limit(2) ->get(['date', 'price_usd']); if ($rows->count() < 2) { return null; } $latest = (float) $rows[0]->price_usd; $prior = (float) $rows[1]->price_usd; if ($prior === 0.0) { return null; } $pctMove = abs(($latest - $prior) / $prior) * 100; if ($pctMove <= self::BRENT_MOVE_PCT) { return null; } $direction = $latest > $prior ? '+' : '-'; return [ 'type' => 'brent_move', 'detail' => sprintf('Brent %s%.2f%% (%s → %s)', $direction, $pctMove, $rows[1]->date->toDateString(), $rows[0]->date->toDateString()), ]; } /** @return array{type: string, detail: string}|null */ private function llmEventTrigger(): ?array { $latest = LlmOverlay::query()->orderByDesc('ran_at')->first(); if ($latest === null || ! $latest->major_impact_event) { return null; } $hasVerifiedUrl = collect((array) $latest->events_json) ->contains(fn ($e): bool => is_array($e) && ! empty($e['url'])); if (! $hasVerifiedUrl) { return null; } $headline = collect((array) $latest->events_json)->pluck('headline')->filter()->first(); return [ 'type' => 'llm_event', 'detail' => sprintf('LLM major impact: %s', $headline ?? 'unspecified'), ]; } /** @return array{type: string, detail: string}|null */ private function stationChurnTrigger(): ?array { if (! $this->stationChurnEnabled()) { return null; } $oldest = DB::table('station_prices')->min('price_effective_at'); if ($oldest === null) { return null; } $pollingDays = (int) abs(now()->diffInDays($oldest)); if ($pollingDays < self::STATION_CHURN_MIN_POLLING_DAYS) { return null; } $last24h = (int) DB::table('station_prices') ->where('price_effective_at', '>=', now()->subDay()) ->distinct('station_id') ->count('station_id'); $baseline = (int) DB::table('station_prices') ->where('price_effective_at', '>=', now()->subDays(30)) ->where('price_effective_at', '<', now()->subDay()) ->distinct('station_id') ->count('station_id'); if ($baseline === 0) { return null; } $dailyBaseline = $baseline / 29; // 29 days of history before yesterday if ($last24h <= $dailyBaseline * self::STATION_CHURN_RATIO) { return null; } return [ 'type' => 'station_churn', 'detail' => sprintf('Station churn %d/24h vs %.1f baseline (%.2fx)', $last24h, $dailyBaseline, $last24h / $dailyBaseline), ]; } /** @return array{type: string, detail: string}|null */ private function watchedEventTrigger(): ?array { $row = WatchedEvent::query() ->where('starts_at', '<=', now()) ->where('ends_at', '>=', now()) ->orderBy('starts_at') ->first(); if ($row === null) { return null; } return [ 'type' => 'manual', 'detail' => sprintf('Watched event: %s', $row->label), ]; } private function stationChurnEnabled(): bool { return (bool) config('services.forecasting.station_churn_enabled', false); } /** @param array{type: string, detail: string} $trigger */ private function flipOn(array $trigger): VolatilityRegime { return VolatilityRegime::query()->create([ 'flipped_on_at' => now(), 'flipped_off_at' => null, 'trigger' => $trigger['type'], 'trigger_detail' => $trigger['detail'], 'active' => true, ]); } private function flipOff(VolatilityRegime $row): void { $row->update([ 'flipped_off_at' => now(), 'active' => false, ]); } }