"""Output Formatter — assembles the structured SignalOutput contract. Populates trade plans based on verdict combinations and maps ``SignalOutput`` to the existing ``Recommendation`` schema for trading engine compatibility. Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1, 12.2, 12.3, 12.4, 12.5 """ from __future__ import annotations from datetime import datetime, timezone from services.shared.schemas import ( ActionType, PositionSizing, Recommendation, RecommendationMode, ) from services.signal_engine.config import SignalEngineConfig from services.signal_engine.models import ( DeltaResult, ExitSignal, HeuristicResult, ProbabilisticResult, SignalOutput, TradePlan, Verdict, ) # --------------------------------------------------------------------------- # Position sizing constants # --------------------------------------------------------------------------- # Full position sizing (heuristic-only or dual confirmed) _FULL_POSITION_SIZE_PCT = 0.02 _FULL_MAX_LOSS_PCT = 0.005 # Reduced position sizing for probabilistic-only BUY (50% of standard) _REDUCED_POSITION_SIZE_PCT = 0.01 # Trade plan price levels (relative to entry) _STOP_LOSS_FACTOR = 0.95 _TARGET_1_FACTOR = 1.05 _TARGET_2_FACTOR = 1.10 def _build_trade_plan( price: float, *, dual_confirmed: bool = False, probabilistic_only: bool = False, ) -> TradePlan: """Build a trade plan with position sizing based on confirmation mode. - dual_confirmed: full position sizing with dual_confirmed flag - probabilistic_only: 50% position sizing with probabilistic_only flag - heuristic-only (neither flag): standard full position sizing """ if dual_confirmed: position_size_pct = _FULL_POSITION_SIZE_PCT max_loss_pct = _FULL_MAX_LOSS_PCT elif probabilistic_only: position_size_pct = _REDUCED_POSITION_SIZE_PCT max_loss_pct = _FULL_MAX_LOSS_PCT else: # Heuristic-only BUY position_size_pct = _FULL_POSITION_SIZE_PCT max_loss_pct = _FULL_MAX_LOSS_PCT return TradePlan( entry_price=price, stop_loss=round(price * _STOP_LOSS_FACTOR, 6), target_1=round(price * _TARGET_1_FACTOR, 6), target_2=round(price * _TARGET_2_FACTOR, 6), position_size_pct=position_size_pct, max_loss_pct=max_loss_pct, dual_confirmed=dual_confirmed, probabilistic_only=probabilistic_only, ) def format_output( ticker: str, price: float, heuristic: HeuristicResult, probabilistic: ProbabilisticResult, delta: DeltaResult, exit_signals: list[ExitSignal], config: SignalEngineConfig, ) -> SignalOutput: """Assemble the structured ``SignalOutput`` contract. Trade plan logic: - Both BUY → ``dual_confirmed``, full position sizing - Probabilistic-only BUY → ``probabilistic_only``, 50% position sizing - Heuristic-only BUY → standard position sizing - No BUY → no trade_plan (WATCH/SKIP persisted for analysis) """ heuristic_buy = heuristic.verdict == Verdict.BUY probabilistic_buy = probabilistic.verdict == Verdict.BUY trade_plan: TradePlan | None = None if heuristic_buy and probabilistic_buy: # Both pipelines agree on BUY → dual confirmed trade_plan = _build_trade_plan( price, dual_confirmed=True, probabilistic_only=False ) elif probabilistic_buy and not heuristic_buy: # Probabilistic-only BUY → reduced position sizing trade_plan = _build_trade_plan( price, dual_confirmed=False, probabilistic_only=True ) elif heuristic_buy and not probabilistic_buy: # Heuristic-only BUY → standard position sizing trade_plan = _build_trade_plan( price, dual_confirmed=False, probabilistic_only=False ) # else: No BUY → no trade_plan return SignalOutput( ticker=ticker, timestamp=datetime.now(tz=timezone.utc), price=price, # Heuristic pipeline section heuristic_verdict=heuristic.verdict.value, heuristic_confidence=heuristic.confidence, heuristic_s_total=heuristic.s_total, # Probabilistic pipeline section probabilistic_verdict=probabilistic.verdict.value, probabilistic_p_up=probabilistic.p_up, probabilistic_entropy=probabilistic.entropy, probabilistic_ev_r=probabilistic.ev_r, # Delta analysis section delta_agreement=delta.agreement, delta_confidence_delta=delta.confidence_delta, delta_reasons=delta.disagreement_reasons, # Trade plan and exit signals trade_plan=trade_plan, exit_signals=exit_signals, # Detail payloads for audit heuristic_detail=heuristic.model_dump(), probabilistic_detail=probabilistic.model_dump(), # Pipeline mode metadata pipeline_mode="dual_pipeline", shadow_mode=config.shadow_mode, ) def signal_output_to_recommendation(output: SignalOutput) -> Recommendation: """Map a ``SignalOutput`` to the existing ``Recommendation`` schema. Enables the trading engine to consume dual-pipeline outputs without modification to its core ``evaluate_recommendation`` logic. Confidence mapping: - Dual confirmed: ``max(heuristic_confidence, probabilistic_P_up)`` - Probabilistic only: ``probabilistic_P_up * 0.8`` (20% haircut) - Heuristic only: ``heuristic_confidence`` - No BUY: ``max(heuristic_confidence, probabilistic_P_up)`` Action mapping: - BUY (either pipeline) → ``ActionType.BUY`` - WATCH → ``ActionType.WATCH`` - SKIP → ``ActionType.HOLD`` Mode: always ``RecommendationMode.PAPER_ELIGIBLE`` """ trade_plan = output.trade_plan # Determine confidence based on confirmation mode if trade_plan is not None and trade_plan.dual_confirmed: confidence = max(output.heuristic_confidence, output.probabilistic_p_up) elif trade_plan is not None and trade_plan.probabilistic_only: confidence = output.probabilistic_p_up * 0.8 elif trade_plan is not None: # Heuristic-only BUY confidence = output.heuristic_confidence else: # No trade plan — use the best available confidence confidence = max(output.heuristic_confidence, output.probabilistic_p_up) # Clamp confidence to [0, 1] confidence = max(0.0, min(1.0, confidence)) # Determine action from verdicts h_verdict = output.heuristic_verdict p_verdict = output.probabilistic_verdict if h_verdict == Verdict.BUY.value or p_verdict == Verdict.BUY.value: action = ActionType.BUY elif h_verdict == Verdict.WATCH.value or p_verdict == Verdict.WATCH.value: action = ActionType.WATCH else: action = ActionType.HOLD # Build position sizing from trade plan if available position_sizing = PositionSizing() if trade_plan is not None: position_sizing = PositionSizing( portfolio_pct=trade_plan.position_size_pct, max_loss_pct=trade_plan.max_loss_pct, ) # Build thesis from delta analysis thesis_parts: list[str] = [] if trade_plan is not None and trade_plan.dual_confirmed: thesis_parts.append("Dual-pipeline confirmed BUY signal") elif trade_plan is not None and trade_plan.probabilistic_only: thesis_parts.append("Probabilistic-only BUY signal (reduced sizing)") elif trade_plan is not None: thesis_parts.append("Heuristic-only BUY signal") else: thesis_parts.append(f"No BUY signal (H={h_verdict}, P={p_verdict})") if output.delta_reasons: thesis_parts.append(f"Delta reasons: {', '.join(output.delta_reasons)}") return Recommendation( recommendation_id=output.output_id, ticker=output.ticker, action=action, mode=RecommendationMode.PAPER_ELIGIBLE, confidence=confidence, time_horizon="signal_engine", thesis="; ".join(thesis_parts), position_sizing=position_sizing, pipeline_mode="dual_pipeline", p_bull=output.probabilistic_p_up, expected_value=output.probabilistic_ev_r, generated_at=output.timestamp, )