"""Deterministic recommendation eligibility logic. Evaluates trend summaries against configurable thresholds to decide: - Whether a recommendation should be generated at all - What action type (buy/sell/hold/watch) is appropriate - What execution mode (informational/paper_eligible/live_eligible) is allowed - Position sizing guidance based on portfolio rules All decisions are rule-based with no model involvement. The LLM is only used downstream for optional thesis wording (a separate task). Requirements: 7.1, 7.2, 7.3, 7.4 """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from services.shared.schemas import ( ActionType, PositionSizing, RecommendationMode, TrendDirection, TrendSummary, ) class RejectionReason(str, Enum): """Why a trend summary was deemed ineligible for a recommendation.""" LOW_CONFIDENCE = "low_confidence" LOW_TREND_STRENGTH = "low_trend_strength" HIGH_CONTRADICTION = "high_contradiction" INSUFFICIENT_EVIDENCE = "insufficient_evidence" NEUTRAL_DIRECTION = "neutral_direction" @dataclass(frozen=True) class EligibilityConfig: """Tunable thresholds for recommendation eligibility. All thresholds are deterministic — no model inference involved. """ # --- Gate thresholds (below these → no recommendation) --- min_confidence: float = 0.35 min_trend_strength: float = 0.10 max_contradiction_score: float = 0.60 min_evidence_count: int = 2 # combined supporting + opposing # --- Action mapping thresholds --- # Trend strength above this → buy/sell; below → hold/watch action_strength_threshold: float = 0.25 # Confidence above this → hold (rather than watch) for weak signals hold_confidence_threshold: float = 0.50 # --- Mode escalation thresholds --- # Confidence required for paper_eligible (below → informational) paper_confidence_threshold: float = 0.50 # Confidence required for live_eligible (below → paper at most) live_confidence_threshold: float = 0.70 # Contradiction must be below this for live eligibility live_max_contradiction: float = 0.25 # Minimum evidence count for live eligibility live_min_evidence: int = 5 # --- Position sizing rules (Requirement 7.3) --- # Base portfolio allocation percentage base_portfolio_pct: float = 0.02 # Maximum portfolio allocation percentage max_portfolio_pct: float = 0.05 # Base max loss percentage base_max_loss_pct: float = 0.005 # Maximum max loss percentage max_max_loss_pct: float = 0.01 # Confidence scaling: higher confidence → larger position (linear) confidence_sizing_weight: float = 0.5 # Contradiction penalty: higher contradiction → smaller position contradiction_sizing_penalty: float = 0.3 DEFAULT_ELIGIBILITY_CONFIG = EligibilityConfig() @dataclass class EligibilityResult: """Output of the deterministic eligibility evaluation. Captures the decision, the reasoning, and all inputs used so the full decision trace is reproducible (Requirement 8.3). """ eligible: bool action: ActionType mode: RecommendationMode position_sizing: PositionSizing rejection_reasons: list[RejectionReason] = field(default_factory=list) time_horizon: str = "" invalidation_conditions: list[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Gate checks # --------------------------------------------------------------------------- def _check_gates( summary: TrendSummary, config: EligibilityConfig, ) -> list[RejectionReason]: """Apply hard gate checks. Returns a list of rejection reasons (empty = pass).""" reasons: list[RejectionReason] = [] if summary.confidence < config.min_confidence: reasons.append(RejectionReason.LOW_CONFIDENCE) if summary.trend_strength < config.min_trend_strength: reasons.append(RejectionReason.LOW_TREND_STRENGTH) if summary.contradiction_score > config.max_contradiction_score: reasons.append(RejectionReason.HIGH_CONTRADICTION) evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence) if evidence_count < config.min_evidence_count: reasons.append(RejectionReason.INSUFFICIENT_EVIDENCE) if summary.trend_direction == TrendDirection.NEUTRAL: reasons.append(RejectionReason.NEUTRAL_DIRECTION) return reasons # --------------------------------------------------------------------------- # Action mapping # --------------------------------------------------------------------------- def _determine_action( summary: TrendSummary, config: EligibilityConfig, ) -> ActionType: """Map trend direction and strength to an action type. Strong bullish → BUY, strong bearish → SELL. Weak but directional → HOLD if confidence is decent, else WATCH. Mixed → WATCH. """ direction = summary.trend_direction strength = summary.trend_strength if direction == TrendDirection.MIXED: return ActionType.WATCH if direction == TrendDirection.NEUTRAL: return ActionType.WATCH strong_signal = strength >= config.action_strength_threshold if direction == TrendDirection.BULLISH: if strong_signal: return ActionType.BUY return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH if direction == TrendDirection.BEARISH: if strong_signal: return ActionType.SELL return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH return ActionType.WATCH # --------------------------------------------------------------------------- # Mode escalation # --------------------------------------------------------------------------- def _determine_mode( summary: TrendSummary, action: ActionType, config: EligibilityConfig, ) -> RecommendationMode: """Determine the highest execution mode allowed. WATCH and HOLD actions are always informational — they don't trigger trades. BUY/SELL can escalate to paper_eligible or live_eligible based on confidence, contradiction, and evidence thresholds. """ if action in (ActionType.WATCH, ActionType.HOLD): return RecommendationMode.INFORMATIONAL evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence) # Check live eligibility first (strictest) if ( summary.confidence >= config.live_confidence_threshold and summary.contradiction_score <= config.live_max_contradiction and evidence_count >= config.live_min_evidence ): return RecommendationMode.LIVE_ELIGIBLE # Check paper eligibility if summary.confidence >= config.paper_confidence_threshold: return RecommendationMode.PAPER_ELIGIBLE return RecommendationMode.INFORMATIONAL # --------------------------------------------------------------------------- # Position sizing (Requirement 7.3) # --------------------------------------------------------------------------- def _compute_position_sizing( summary: TrendSummary, config: EligibilityConfig, ) -> PositionSizing: """Compute position sizing guidance from portfolio rules and signal quality. Higher confidence → larger allocation (up to max). Higher contradiction → smaller allocation (penalty). """ # Start from base allocation confidence_scale = config.base_portfolio_pct + ( config.confidence_sizing_weight * summary.confidence * (config.max_portfolio_pct - config.base_portfolio_pct) ) # Apply contradiction penalty contradiction_penalty = config.contradiction_sizing_penalty * summary.contradiction_score portfolio_pct = confidence_scale * (1.0 - contradiction_penalty) # Clamp to bounds portfolio_pct = max(config.base_portfolio_pct * 0.5, min(portfolio_pct, config.max_portfolio_pct)) # Max loss scales similarly loss_scale = config.base_max_loss_pct + ( config.confidence_sizing_weight * summary.confidence * (config.max_max_loss_pct - config.base_max_loss_pct) ) max_loss_pct = loss_scale * (1.0 - contradiction_penalty) max_loss_pct = max(config.base_max_loss_pct * 0.5, min(max_loss_pct, config.max_max_loss_pct)) return PositionSizing( portfolio_pct=round(portfolio_pct, 6), max_loss_pct=round(max_loss_pct, 6), ) # --------------------------------------------------------------------------- # Time horizon mapping # --------------------------------------------------------------------------- _WINDOW_TO_HORIZON: dict[str, str] = { "intraday": "intraday", "1d": "swing_1d_3d", "7d": "swing_1d_10d", "30d": "position_10d_30d", "90d": "position_30d_90d", } def _map_time_horizon(window: str) -> str: """Map a trend window to a human-readable time horizon label.""" return _WINDOW_TO_HORIZON.get(window, f"window_{window}") # --------------------------------------------------------------------------- # Invalidation conditions # --------------------------------------------------------------------------- def _derive_invalidation_conditions( summary: TrendSummary, action: ActionType, ) -> list[str]: """Generate deterministic invalidation conditions for the recommendation. These describe when the recommendation should be considered stale or wrong. """ conditions: list[str] = [] if action == ActionType.BUY: conditions.append( f"Trend direction for {summary.entity_id} reverses to bearish" ) elif action == ActionType.SELL: conditions.append( f"Trend direction for {summary.entity_id} reverses to bullish" ) if summary.contradiction_score > 0.0: conditions.append( f"Contradiction score exceeds 0.60 (currently {summary.contradiction_score:.2f})" ) if summary.confidence > 0.0: conditions.append( f"Confidence drops below {summary.confidence * 0.7:.2f}" ) if summary.material_risks: conditions.append( f"Material risk materialises: {summary.material_risks[0]}" ) return conditions # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- def evaluate_eligibility( summary: TrendSummary, config: EligibilityConfig = DEFAULT_ELIGIBILITY_CONFIG, ) -> EligibilityResult: """Evaluate a trend summary for recommendation eligibility. This is the single deterministic entry point. It: 1. Applies gate checks (confidence, strength, contradiction, evidence) 2. Maps trend direction + strength to an action type 3. Determines the highest allowed execution mode 4. Computes position sizing from portfolio rules 5. Derives invalidation conditions Returns an EligibilityResult with the full decision trace. """ rejection_reasons = _check_gates(summary, config) # Even if rejected, we still compute action/mode for the trace action = _determine_action(summary, config) mode = _determine_mode(summary, action, config) sizing = _compute_position_sizing(summary, config) horizon = _map_time_horizon(summary.window.value) invalidation = _derive_invalidation_conditions(summary, action) eligible = len(rejection_reasons) == 0 # If not eligible, force mode to informational (Requirement 7.4) if not eligible: mode = RecommendationMode.INFORMATIONAL return EligibilityResult( eligible=eligible, action=action, mode=mode, position_sizing=sizing, rejection_reasons=rejection_reasons, time_horizon=horizon, invalidation_conditions=invalidation, )