"""Unit tests for services.signal_engine.formatter — Output Formatter. Tests trade plan generation for dual_confirmed, probabilistic_only, heuristic-only, and no-BUY cases. Also tests the ``signal_output_to_recommendation`` mapping to the existing ``Recommendation`` schema. Requirements: 10.2, 10.3, 10.4, 12.3, 12.4 """ from __future__ import annotations from services.shared.schemas import ActionType, RecommendationMode from services.signal_engine.config import SignalEngineConfig from services.signal_engine.formatter import ( format_output, signal_output_to_recommendation, ) from services.signal_engine.models import ( DeltaResult, ExitSignal, ExitType, HeuristicResult, ProbabilisticResult, Verdict, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _default_config() -> SignalEngineConfig: return SignalEngineConfig() def _heuristic( verdict: Verdict = Verdict.BUY, confidence: float = 0.85, s_total: float = 1.5, ) -> HeuristicResult: return HeuristicResult( verdict=verdict, confidence=confidence, s_total=s_total, s_company=1.0, s_macro=0.3, s_competitive=0.2, signal_weights=[], reasoning=[f"{verdict.value} verdict"], ) def _probabilistic( verdict: Verdict = Verdict.BUY, p_up: float = 0.75, entropy: float = 0.6, ev_r: float = 2.0, ) -> ProbabilisticResult: return ProbabilisticResult( verdict=verdict, p_up=p_up, entropy=entropy, ev_r=ev_r, prior=0.58, posterior=0.75, likelihood_ratios=[], regime="bull", reasoning=[f"{verdict.value} verdict"], ) def _delta( agreement: bool = True, confidence_delta: float = 0.1, reasons: list[str] | None = None, ) -> DeltaResult: return DeltaResult( agreement=agreement, confidence_delta=confidence_delta, heuristic_verdict="BUY", probabilistic_verdict="BUY", disagreement_reasons=reasons or [], rolling_agreement_rate=0.85, ) # =========================================================================== # 1. Dual confirmed trade plan (Requirement 10.4) # =========================================================================== class TestDualConfirmed: """Both pipelines BUY → dual_confirmed, full position sizing.""" def test_dual_confirmed_trade_plan(self) -> None: """Both BUY → trade_plan with dual_confirmed=True.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.dual_confirmed is True assert output.trade_plan.probabilistic_only is False def test_dual_confirmed_full_position_sizing(self) -> None: """Dual confirmed → position_size_pct=0.02, max_loss_pct=0.005.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.position_size_pct == 0.02 assert output.trade_plan.max_loss_pct == 0.005 def test_dual_confirmed_price_levels(self) -> None: """Trade plan price levels: stop=95%, target_1=105%, target_2=110%.""" price = 200.0 output = format_output( ticker="AAPL", price=price, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) tp = output.trade_plan assert tp is not None assert tp.entry_price == price assert abs(tp.stop_loss - price * 0.95) < 1e-6 assert abs(tp.target_1 - price * 1.05) < 1e-6 assert abs(tp.target_2 - price * 1.10) < 1e-6 # =========================================================================== # 2. Probabilistic-only trade plan (Requirement 10.3) # =========================================================================== class TestProbabilisticOnly: """Probabilistic BUY, heuristic not BUY → probabilistic_only, 50% sizing.""" def test_probabilistic_only_trade_plan(self) -> None: """Probabilistic BUY + heuristic WATCH → probabilistic_only flag.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.probabilistic_only is True assert output.trade_plan.dual_confirmed is False def test_probabilistic_only_reduced_sizing(self) -> None: """Probabilistic-only → position_size_pct=0.01 (50% of standard).""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.SKIP, confidence=0.40), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.position_size_pct == 0.01 assert output.trade_plan.max_loss_pct == 0.005 def test_probabilistic_only_price_levels(self) -> None: """Price levels are the same regardless of confirmation mode.""" price = 100.0 output = format_output( ticker="MSFT", price=price, heuristic=_heuristic(Verdict.WATCH), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) tp = output.trade_plan assert tp is not None assert tp.entry_price == price assert abs(tp.stop_loss - price * 0.95) < 1e-6 assert abs(tp.target_1 - price * 1.05) < 1e-6 assert abs(tp.target_2 - price * 1.10) < 1e-6 # =========================================================================== # 3. Heuristic-only trade plan (Requirement 10.2) # =========================================================================== class TestHeuristicOnly: """Heuristic BUY, probabilistic not BUY → standard position sizing.""" def test_heuristic_only_trade_plan(self) -> None: """Heuristic BUY + probabilistic WATCH → standard trade plan.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.dual_confirmed is False assert output.trade_plan.probabilistic_only is False def test_heuristic_only_full_sizing(self) -> None: """Heuristic-only → position_size_pct=0.02 (full standard).""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) assert output.trade_plan is not None assert output.trade_plan.position_size_pct == 0.02 assert output.trade_plan.max_loss_pct == 0.005 # =========================================================================== # 4. No BUY — no trade plan (Requirement 10.4 inverse) # =========================================================================== class TestNoBuy: """Neither pipeline BUY → no trade_plan.""" def test_both_watch_no_trade_plan(self) -> None: """Both WATCH → no trade_plan.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.trade_plan is None def test_both_skip_no_trade_plan(self) -> None: """Both SKIP → no trade_plan.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.SKIP, confidence=0.30), probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.trade_plan is None def test_watch_and_skip_no_trade_plan(self) -> None: """WATCH + SKIP → no trade_plan.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) assert output.trade_plan is None def test_no_buy_still_has_pipeline_data(self) -> None: """Even without trade_plan, pipeline data is populated for analysis.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.heuristic_verdict == "WATCH" assert output.probabilistic_verdict == "WATCH" assert output.heuristic_confidence == 0.60 assert output.probabilistic_p_up == 0.57 assert output.delta_agreement is True # =========================================================================== # 5. signal_output_to_recommendation mapping (Requirements 12.3, 12.4) # =========================================================================== class TestSignalOutputToRecommendation: """Map SignalOutput to existing Recommendation schema.""" def test_dual_confirmed_confidence(self) -> None: """Dual confirmed → confidence = max(heuristic, probabilistic).""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY, confidence=0.85), probabilistic=_probabilistic(Verdict.BUY, p_up=0.75), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.confidence == max(0.85, 0.75) assert rec.confidence == 0.85 def test_probabilistic_only_confidence_haircut(self) -> None: """Probabilistic only → confidence = P_up * 0.8 (20% haircut).""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.BUY, p_up=0.75), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert abs(rec.confidence - 0.75 * 0.8) < 1e-9 assert abs(rec.confidence - 0.6) < 1e-9 def test_heuristic_only_confidence(self) -> None: """Heuristic only → confidence = heuristic_confidence.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY, confidence=0.80), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.confidence == 0.80 def test_buy_action_mapping(self) -> None: """Any BUY verdict → ActionType.BUY.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=False), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.action == ActionType.BUY def test_watch_action_mapping(self) -> None: """Both WATCH → ActionType.WATCH.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.WATCH, confidence=0.60), probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.action == ActionType.WATCH def test_skip_action_mapping(self) -> None: """Both SKIP → ActionType.HOLD.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.SKIP, confidence=0.30), probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.action == ActionType.HOLD def test_recommendation_mode_paper_eligible(self) -> None: """All recommendations use PAPER_ELIGIBLE mode.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.mode == RecommendationMode.PAPER_ELIGIBLE def test_recommendation_position_sizing_from_trade_plan(self) -> None: """Position sizing in recommendation matches trade plan.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.position_sizing.portfolio_pct == 0.02 assert rec.position_sizing.max_loss_pct == 0.005 def test_recommendation_probabilistic_fields(self) -> None: """Recommendation includes probabilistic pipeline fields.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, ev_r=2.0), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.p_bull == 0.75 assert rec.expected_value == 2.0 assert rec.pipeline_mode == "dual_pipeline" def test_recommendation_ticker_and_id(self) -> None: """Recommendation inherits ticker and output_id.""" output = format_output( ticker="MSFT", price=300.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) rec = signal_output_to_recommendation(output) assert rec.ticker == "MSFT" assert rec.recommendation_id == output.output_id # =========================================================================== # 6. SignalOutput structure and metadata # =========================================================================== class TestSignalOutputStructure: """Verify SignalOutput has all required fields populated.""" def test_output_has_all_pipeline_data(self) -> None: """SignalOutput contains heuristic, probabilistic, and delta sections.""" output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY, confidence=0.85, s_total=1.5), probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, entropy=0.6, ev_r=2.0), delta=_delta(agreement=True, confidence_delta=0.1), exit_signals=[], config=_default_config(), ) assert output.ticker == "AAPL" assert output.price == 150.0 assert output.heuristic_verdict == "BUY" assert output.heuristic_confidence == 0.85 assert output.heuristic_s_total == 1.5 assert output.probabilistic_verdict == "BUY" assert output.probabilistic_p_up == 0.75 assert output.probabilistic_entropy == 0.6 assert output.probabilistic_ev_r == 2.0 assert output.delta_agreement is True assert output.delta_confidence_delta == 0.1 assert output.pipeline_mode == "dual_pipeline" def test_output_includes_exit_signals(self) -> None: """Exit signals are passed through to the output.""" exits = [ ExitSignal( position_id="pos-1", ticker="AAPL", exit_type=ExitType.EXIT_HALF, reason="target_1_hit", price=157.5, ), ] output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=exits, config=_default_config(), ) assert len(output.exit_signals) == 1 assert output.exit_signals[0].position_id == "pos-1" assert output.exit_signals[0].reason == "target_1_hit" def test_output_shadow_mode(self) -> None: """Shadow mode flag is propagated from config.""" config = SignalEngineConfig(shadow_mode=True) output = format_output( ticker="AAPL", price=150.0, heuristic=_heuristic(Verdict.BUY), probabilistic=_probabilistic(Verdict.BUY), delta=_delta(agreement=True), exit_signals=[], config=config, ) assert output.shadow_mode is True def test_output_detail_payloads(self) -> None: """Heuristic and probabilistic detail payloads are populated for audit.""" h = _heuristic(Verdict.BUY, confidence=0.85) p = _probabilistic(Verdict.BUY, p_up=0.75) output = format_output( ticker="AAPL", price=150.0, heuristic=h, probabilistic=p, delta=_delta(agreement=True), exit_signals=[], config=_default_config(), ) assert output.heuristic_detail["verdict"] == "BUY" assert output.heuristic_detail["confidence"] == 0.85 assert output.probabilistic_detail["verdict"] == "BUY" assert output.probabilistic_detail["p_up"] == 0.75