"""Integration tests for services.signal_engine.worker — Top-level orchestrator. Tests the full evaluation tick flow with mocked DB/Redis, pipeline failure isolation, hard filter short-circuit, and shadow mode behavior. Requirements: 11.1, 11.2, 11.3, 11.6, 13.1, 13.6, 13.7, 15.1, 15.4, 16.1, 16.6 """ from __future__ import annotations from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from services.signal_engine.config import SignalEngineConfig from services.signal_engine.models import ( DeltaResult, HeuristicResult, NormalizedInput, ProbabilisticResult, Verdict, ) from services.signal_engine.worker import evaluate_tick # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _default_config(**overrides: object) -> SignalEngineConfig: """Build a SignalEngineConfig with sensible test defaults.""" defaults = { "dual_pipeline_enabled": True, "shadow_mode": False, } defaults.update(overrides) return SignalEngineConfig(**defaults) def _normalized_input( *, ticker: str = "AAPL", macro_bias: float = 0.5, valuation_score: float = 0.8, earnings_proximity_days: int = 30, current_price: float = 150.0, ) -> NormalizedInput: """Build a NormalizedInput with test defaults that pass hard filters.""" return NormalizedInput( ticker=ticker, evaluated_at=datetime.now(tz=timezone.utc), bars={}, valuation_score=valuation_score, earnings_proximity_days=earnings_proximity_days, macro_bias=macro_bias, open_positions=[], closing_prices=[100.0 + i for i in range(120)], returns=[0.01] * 119, current_price=current_price, ) def _heuristic_buy() -> HeuristicResult: return HeuristicResult( verdict=Verdict.BUY, confidence=0.85, s_total=1.5, s_company=1.0, s_macro=0.3, s_competitive=0.2, signal_weights=[], reasoning=["BUY: all conditions met"], ) def _heuristic_skip() -> HeuristicResult: return HeuristicResult( verdict=Verdict.SKIP, confidence=0.3, s_total=0.5, s_company=0.3, s_macro=0.1, s_competitive=0.1, signal_weights=[], reasoning=["SKIP: low confidence"], ) def _probabilistic_buy() -> ProbabilisticResult: return ProbabilisticResult( verdict=Verdict.BUY, p_up=0.72, entropy=0.6, ev_r=2.0, prior=0.58, posterior=0.72, likelihood_ratios=[], regime="trend_following", reasoning=["BUY: all conditions met"], ) def _probabilistic_skip() -> ProbabilisticResult: return ProbabilisticResult( verdict=Verdict.SKIP, p_up=0.4, entropy=0.95, ev_r=0.5, prior=0.5, posterior=0.4, likelihood_ratios=[], regime="uncertainty", reasoning=["SKIP: low P_up"], ) def _delta_result(agreement: bool = True) -> DeltaResult: return DeltaResult( agreement=agreement, confidence_delta=0.1, heuristic_verdict="BUY", probabilistic_verdict="BUY", disagreement_reasons=[], rolling_agreement_rate=0.9, ) # =========================================================================== # 1. Full tick evaluation with mocked data (Req 11.1, 11.2, 11.5, 11.6) # =========================================================================== class TestFullTickEvaluation: """Test the full evaluation tick with both pipelines producing BUY.""" @pytest.mark.asyncio async def test_full_tick_both_buy_publishes_to_queue(self) -> None: """Both pipelines BUY → output persisted and published to trading queue.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input() heuristic = _heuristic_buy() probabilistic = _probabilistic_buy() delta = _delta_result(agreement=True) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", return_value=heuristic, ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", return_value=probabilistic, ), patch( "services.signal_engine.worker.analyze_delta", new_callable=AsyncMock, return_value=delta, ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is not None assert output.ticker == "AAPL" assert output.heuristic_verdict == "BUY" assert output.probabilistic_verdict == "BUY" # Persistence was called mock_persist.assert_awaited_once() # Trading queue was published to (at least one BUY, not shadow mode) redis_client.rpush.assert_awaited_once() call_args = redis_client.rpush.call_args assert call_args[0][0] == "stonks:queue:trading_decisions" @pytest.mark.asyncio async def test_full_tick_no_buy_does_not_publish(self) -> None: """Both pipelines SKIP → output persisted but NOT published to queue.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input() heuristic = _heuristic_skip() probabilistic = _probabilistic_skip() delta = _delta_result(agreement=True) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", return_value=heuristic, ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", return_value=probabilistic, ), patch( "services.signal_engine.worker.analyze_delta", new_callable=AsyncMock, return_value=delta, ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is not None # Persisted mock_persist.assert_awaited_once() # NOT published to trading queue redis_client.rpush.assert_not_awaited() # =========================================================================== # 2. Pipeline failure isolation (Req 11.3) # =========================================================================== class TestPipelineFailureIsolation: """One pipeline fails → SKIP verdict for that pipeline, other completes.""" @pytest.mark.asyncio async def test_heuristic_fails_probabilistic_completes(self) -> None: """Heuristic raises exception → SKIP, probabilistic completes normally.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input() probabilistic = _probabilistic_buy() delta = _delta_result(agreement=False) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", side_effect=RuntimeError("heuristic boom"), ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", return_value=probabilistic, ), patch( "services.signal_engine.worker.analyze_delta", new_callable=AsyncMock, return_value=delta, ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is not None # Heuristic fell back to SKIP assert output.heuristic_verdict == "SKIP" # Probabilistic completed normally assert output.probabilistic_verdict == "BUY" # Still persisted mock_persist.assert_awaited_once() @pytest.mark.asyncio async def test_probabilistic_fails_heuristic_completes(self) -> None: """Probabilistic raises exception → SKIP, heuristic completes normally.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input() heuristic = _heuristic_buy() delta = _delta_result(agreement=False) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", return_value=heuristic, ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", side_effect=RuntimeError("probabilistic boom"), ), patch( "services.signal_engine.worker.analyze_delta", new_callable=AsyncMock, return_value=delta, ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is not None assert output.heuristic_verdict == "BUY" assert output.probabilistic_verdict == "SKIP" mock_persist.assert_awaited_once() @pytest.mark.asyncio async def test_both_pipelines_fail_returns_none(self) -> None: """Both pipelines raise exceptions → returns None.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input() with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", side_effect=RuntimeError("heuristic boom"), ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", side_effect=RuntimeError("probabilistic boom"), ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is None # Nothing persisted when both fail mock_persist.assert_not_awaited() # =========================================================================== # 3. Hard filter short-circuit (Req 4.1, 4.2, 4.3) # =========================================================================== class TestHardFilterShortCircuit: """Hard filter triggers → returns None without running pipelines.""" @pytest.mark.asyncio async def test_hard_filter_returns_none(self) -> None: """When hard filter triggers, evaluate_tick returns None.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config() normalized = _normalized_input(macro_bias=-1.0) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker.run_heuristic_pipeline", ) as mock_heuristic, patch( "services.signal_engine.worker.run_probabilistic_pipeline", ) as mock_probabilistic, patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock( filtered=True, reasons=["macro_bias_negative"] ) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is None # Pipelines were NOT called mock_heuristic.assert_not_called() mock_probabilistic.assert_not_called() # Nothing persisted mock_persist.assert_not_awaited() # =========================================================================== # 4. Shadow mode behavior (Req 16.6) # =========================================================================== class TestShadowMode: """Shadow mode: persists output but does NOT publish to trading queue.""" @pytest.mark.asyncio async def test_shadow_mode_persists_but_does_not_publish(self) -> None: """In shadow mode, BUY signals are persisted but not forwarded.""" pool = AsyncMock() redis_client = AsyncMock() config = _default_config(shadow_mode=True) normalized = _normalized_input() heuristic = _heuristic_buy() probabilistic = _probabilistic_buy() delta = _delta_result(agreement=True) with ( patch( "services.signal_engine.worker.normalize_input", new_callable=AsyncMock, return_value=normalized, ), patch( "services.signal_engine.worker.evaluate_exits", return_value=[], ), patch( "services.signal_engine.worker.evaluate_hard_filters", ) as mock_hf, patch( "services.signal_engine.worker._evaluate_signals", return_value={}, ), patch( "services.signal_engine.worker.compute_confluence", return_value=[], ), patch( "services.signal_engine.worker.classify_regime", ), patch( "services.signal_engine.worker.run_heuristic_pipeline", return_value=heuristic, ), patch( "services.signal_engine.worker.run_probabilistic_pipeline", return_value=probabilistic, ), patch( "services.signal_engine.worker.analyze_delta", new_callable=AsyncMock, return_value=delta, ), patch( "services.signal_engine.worker.persist_signal_output", new_callable=AsyncMock, ) as mock_persist, ): mock_hf.return_value = MagicMock(filtered=False, reasons=[]) output = await evaluate_tick(pool, redis_client, "AAPL", config) assert output is not None assert output.heuristic_verdict == "BUY" assert output.probabilistic_verdict == "BUY" # Persisted (shadow mode still persists) mock_persist.assert_awaited_once() # NOT published to trading queue (shadow mode blocks publishing) redis_client.rpush.assert_not_awaited()