evaluate(); expect($result)->toBeNull() ->and(VolatilityRegime::query()->count())->toBe(0); }); it('flips ON when Brent moves more than 3% close-to-close', function (): void { BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 84.00]); // +5% $service = makeVolatilityService(); $row = $service->evaluate(); expect($row)->not->toBeNull() ->and($row->trigger)->toBe('brent_move') ->and($row->active)->toBeTrue(); }); it('does NOT flip on a 2% Brent move (below threshold)', function (): void { BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 81.50]); // +1.875% $service = makeVolatilityService(); expect($service->evaluate())->toBeNull(); }); it('flips ON when the most recent llm_overlay flags a major impact event', function (): void { LlmOverlay::query()->create([ 'ran_at' => now(), 'forecast_for_week' => Carbon::now()->next(Carbon::MONDAY)->toDateString(), 'direction' => 'rising', 'confidence' => 60, 'reasoning' => 'OPEC unexpected cut.', 'events_json' => [['headline' => 'OPEC cut', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising']], 'agrees_with_ridge' => true, 'major_impact_event' => true, 'volatility_flag_on' => false, 'search_used' => true, ]); $service = makeVolatilityService(); $row = $service->evaluate(); expect($row)->not->toBeNull() ->and($row->trigger)->toBe('llm_event'); }); it('does NOT flip on llm_overlay when no URL is verified', function (): void { LlmOverlay::query()->create([ 'ran_at' => now(), 'forecast_for_week' => Carbon::now()->next(Carbon::MONDAY)->toDateString(), 'direction' => 'rising', 'confidence' => 60, 'reasoning' => '...', 'events_json' => [['headline' => 'OPEC cut', 'source' => 'Reuters', 'url' => '', 'impact' => 'rising']], 'agrees_with_ridge' => true, 'major_impact_event' => true, 'volatility_flag_on' => false, 'search_used' => true, ]); $service = makeVolatilityService(); expect($service->evaluate())->toBeNull(); }); it('flips ON when a watched_event covers today', function (): void { WatchedEvent::query()->create([ 'label' => 'Iran tensions', 'starts_at' => Carbon::now()->subDay(), 'ends_at' => Carbon::now()->addWeek(), 'notes' => 'manually flagged', ]); $service = makeVolatilityService(); $row = $service->evaluate(); expect($row)->not->toBeNull() ->and($row->trigger)->toBe('manual') ->and($row->trigger_detail)->toContain('Iran tensions'); }); it('flips OFF when no triggers fire while a regime is active', function (): void { $existing = VolatilityRegime::query()->create([ 'flipped_on_at' => now()->subDay(), 'flipped_off_at' => null, 'trigger' => 'brent_move', 'trigger_detail' => 'Brent +4.2%', 'active' => true, ]); $service = makeVolatilityService(); $result = $service->evaluate(); expect($result)->toBeNull(); $existing->refresh(); expect($existing->active)->toBeFalse() ->and($existing->flipped_off_at)->not->toBeNull(); }); it('keeps the existing regime when a trigger still fires', function (): void { BrentPrice::query()->create(['date' => '2026-04-26', 'price_usd' => 80.00]); BrentPrice::query()->create(['date' => '2026-04-27', 'price_usd' => 84.00]); $existing = VolatilityRegime::query()->create([ 'flipped_on_at' => now()->subHour(), 'flipped_off_at' => null, 'trigger' => 'brent_move', 'trigger_detail' => 'Brent +5%', 'active' => true, ]); $service = makeVolatilityService(); $result = $service->evaluate(); expect($result?->id)->toBe($existing->id) ->and(VolatilityRegime::query()->count())->toBe(1); }); it('skips station_churn trigger when feature flag is off (default)', function (): void { Config::set('services.forecasting.station_churn_enabled', false); $service = makeVolatilityService(); expect($service->evaluate())->toBeNull(); });