"""Smoke-level orchestrator tests for the Kevin signal bridge.""" from datetime import date, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock from zoneinfo import ZoneInfo from services.kevin_signal_bridge.main import KevinBridge, should_run_exit_scan _ET = ZoneInfo("America/New_York") async def test_bridge_dry_run_writes_audit_does_not_publish(): """When TRADING_KEVIN_ENABLE_TRADING=false the bridge writes an audit row and never calls publisher.publish.""" config = MagicMock(kevin_enable_trading=False, kevin_stop_loss_pct=0.08, kevin_take_profit_pct=0.20, kevin_avoid_blocks_days=7) cursor = AsyncMock() cursor.last_seen_id.return_value = 0 publisher = AsyncMock() aggregator = AsyncMock() aggregator.fetch_pending.return_value = [ MagicMock( id=1, symbol="NVDA", action=MagicMock(value="buy"), conviction=Decimal("0.8"), time_horizon=MagicMock(value="weeks"), ), ] strategy = AsyncMock() strategy.evaluate_mention.return_value = MagicMock( decision=MagicMock(value="open_long", name="OPEN_LONG"), symbol="NVDA", target_dollars=Decimal("3000"), holding_days=10, effective_conviction=Decimal("0.8"), rationale="ok", ) # Make the .decision attribute compare against KevinDecisionType.OPEN_LONG from shared.schemas.kevin import KevinDecisionType strategy.evaluate_mention.return_value.decision = KevinDecisionType.OPEN_LONG audit_writer = AsyncMock() broker = AsyncMock() broker.is_asset_tradable.return_value = True broker.get_latest_price.return_value = Decimal("100") broker.get_account.return_value = MagicMock( equity=Decimal("100000"), cash=Decimal("100000") ) broker.get_positions.return_value = [] bridge = KevinBridge( config=config, cursor=cursor, publisher=publisher, aggregator=aggregator, strategy=strategy, audit_writer=audit_writer, broker=broker, ) await bridge.process_one_pass() publisher.publish.assert_not_called() audit_writer.write.assert_awaited() args = audit_writer.write.call_args assert "dry_run" in str(args).lower() async def test_bridge_kill_switch_on_publishes_to_stream(): config = MagicMock( kevin_enable_trading=True, kevin_stop_loss_pct=0.08, kevin_take_profit_pct=0.20, kevin_avoid_blocks_days=7, ) cursor = AsyncMock() cursor.last_seen_id.return_value = 0 publisher = AsyncMock() publisher.publish.return_value = "1234-0" aggregator = AsyncMock() aggregator.fetch_pending.return_value = [ MagicMock( id=1, symbol="NVDA", action=MagicMock(value="buy"), conviction=Decimal("0.8"), effective_conviction=Decimal("0.8"), time_horizon=MagicMock(value="weeks"), ), ] strategy = AsyncMock() from shared.schemas.kevin import KevinDecisionType strategy.evaluate_mention.return_value = MagicMock( decision=KevinDecisionType.OPEN_LONG, symbol="NVDA", target_dollars=Decimal("3000"), holding_days=10, effective_conviction=Decimal("0.8"), rationale="ok", ) audit_writer = AsyncMock() broker = AsyncMock() broker.is_asset_tradable.return_value = True broker.get_latest_price.return_value = Decimal("100") broker.get_account.return_value = MagicMock( equity=Decimal("100000"), cash=Decimal("100000") ) broker.get_positions.return_value = [] bridge = KevinBridge( config=config, cursor=cursor, publisher=publisher, aggregator=aggregator, strategy=strategy, audit_writer=audit_writer, broker=broker, ) await bridge.process_one_pass() publisher.publish.assert_awaited_once() cursor.advance.assert_awaited_with(1) async def test_bridge_attaches_current_price_to_signal(): """The bridge must propagate broker.get_latest_price onto the published TradeSignal so the executor can size the position.""" config = MagicMock( kevin_enable_trading=True, kevin_stop_loss_pct=0.08, kevin_take_profit_pct=0.20, kevin_avoid_blocks_days=7, ) cursor = AsyncMock() cursor.last_seen_id.return_value = 0 publisher = AsyncMock() publisher.publish.return_value = "1234-0" aggregator = AsyncMock() aggregator.fetch_pending.return_value = [ MagicMock( id=1, symbol="NVDA", action=MagicMock(value="buy"), conviction=Decimal("0.8"), effective_conviction=Decimal("0.8"), time_horizon=MagicMock(value="weeks"), ), ] strategy = AsyncMock() from shared.schemas.kevin import KevinDecisionType strategy.evaluate_mention.return_value = MagicMock( decision=KevinDecisionType.OPEN_LONG, symbol="NVDA", target_dollars=Decimal("3000"), holding_days=10, effective_conviction=Decimal("0.8"), rationale="ok", ) audit_writer = AsyncMock() broker = AsyncMock() broker.is_asset_tradable.return_value = True broker.get_latest_price.return_value = Decimal("123.45") broker.get_account.return_value = MagicMock( equity=Decimal("100000"), cash=Decimal("100000") ) broker.get_positions.return_value = [] bridge = KevinBridge( config=config, cursor=cursor, publisher=publisher, aggregator=aggregator, strategy=strategy, audit_writer=audit_writer, broker=broker, ) await bridge.process_one_pass() publisher.publish.assert_awaited_once() published_signal = publisher.publish.call_args[0][0] assert published_signal.current_price == Decimal("123.45") async def test_bridge_advances_cursor_only_after_publish(): """Race-condition guard: cursor must NOT advance if publish raises.""" config = MagicMock( kevin_enable_trading=True, kevin_stop_loss_pct=0.08, kevin_take_profit_pct=0.20, kevin_avoid_blocks_days=7, ) cursor = AsyncMock() cursor.last_seen_id.return_value = 0 publisher = AsyncMock() publisher.publish.side_effect = RuntimeError("XADD failed") aggregator = AsyncMock() aggregator.fetch_pending.return_value = [ MagicMock( id=1, symbol="NVDA", action=MagicMock(value="buy"), conviction=Decimal("0.8"), effective_conviction=Decimal("0.8"), time_horizon=MagicMock(value="weeks"), ), ] strategy = AsyncMock() from shared.schemas.kevin import KevinDecisionType strategy.evaluate_mention.return_value = MagicMock( decision=KevinDecisionType.OPEN_LONG, symbol="NVDA", target_dollars=Decimal("3000"), holding_days=10, effective_conviction=Decimal("0.8"), rationale="ok", ) audit_writer = AsyncMock() broker = AsyncMock() broker.is_asset_tradable.return_value = True broker.get_latest_price.return_value = Decimal("100") broker.get_account.return_value = MagicMock( equity=Decimal("100000"), cash=Decimal("100000") ) broker.get_positions.return_value = [] bridge = KevinBridge( config=config, cursor=cursor, publisher=publisher, aggregator=aggregator, strategy=strategy, audit_writer=audit_writer, broker=broker, ) await bridge.process_one_pass() # cursor.advance must NOT have been called for mention 1 cursor.advance.assert_not_called() # --------------------------------------------------------------------------- # Exit-scan cron gate (croniter unavailable → simple daily ET-weekday gate) # --------------------------------------------------------------------------- _CRON = "35 9 * * 1-5" # 09:35 ET, Mon-Fri def test_exit_scan_gate_fires_at_or_after_time_on_weekday(): """Wed 09:36 ET, not yet run today → fire.""" now = datetime(2026, 6, 3, 9, 36, tzinfo=_ET) # Wednesday assert should_run_exit_scan(_CRON, now, last_run_date=None) is True def test_exit_scan_gate_does_not_fire_before_time(): """Wed 09:34 ET (one minute early) → do not fire.""" now = datetime(2026, 6, 3, 9, 34, tzinfo=_ET) assert should_run_exit_scan(_CRON, now, last_run_date=None) is False def test_exit_scan_gate_does_not_fire_on_weekend(): """Saturday 10:00 ET → do not fire (cron DOW is 1-5).""" now = datetime(2026, 6, 6, 10, 0, tzinfo=_ET) # Saturday assert should_run_exit_scan(_CRON, now, last_run_date=None) is False def test_exit_scan_gate_runs_once_per_day(): """Already ran today → do not fire again the same ET day.""" now = datetime(2026, 6, 3, 11, 0, tzinfo=_ET) # Wednesday, well past 09:35 assert ( should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is False ) def test_exit_scan_gate_fires_next_day_after_prior_run(): """Ran yesterday → fire again today once past the cron time.""" now = datetime(2026, 6, 4, 9, 40, tzinfo=_ET) # Thursday assert ( should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is True )