"""Tests for deterministic recommendation eligibility logic.""" from typing import Any from services.recommendation.eligibility import ( DEFAULT_ELIGIBILITY_CONFIG, EligibilityConfig, RejectionReason, evaluate_eligibility, ) from services.shared.schemas import ( ActionType, RecommendationMode, TrendDirection, TrendSummary, TrendWindow, ) def _make_summary(**overrides: Any) -> TrendSummary: """Build a TrendSummary with sensible defaults for testing.""" defaults = dict( entity_type="company", entity_id="AAPL", window=TrendWindow.SEVEN_DAY, trend_direction=TrendDirection.BULLISH, trend_strength=0.5, confidence=0.6, top_supporting_evidence=["doc1", "doc2", "doc3"], top_opposing_evidence=[], dominant_catalysts=["earnings"], material_risks=["regulatory scrutiny"], contradiction_score=0.1, ) defaults.update(overrides) return TrendSummary(**defaults) # --------------------------------------------------------------------------- # Gate checks # --------------------------------------------------------------------------- def test_eligible_strong_bullish(): """A strong bullish trend with good confidence passes all gates.""" summary = _make_summary( trend_strength=0.5, confidence=0.6, contradiction_score=0.1, ) result = evaluate_eligibility(summary) assert result.eligible is True assert result.rejection_reasons == [] assert result.action == ActionType.BUY def test_rejected_low_confidence(): """Below min_confidence → rejected.""" summary = _make_summary(confidence=0.2) result = evaluate_eligibility(summary) assert result.eligible is False assert RejectionReason.LOW_CONFIDENCE in result.rejection_reasons def test_rejected_low_strength(): """Below min_trend_strength → rejected.""" summary = _make_summary(trend_strength=0.05) result = evaluate_eligibility(summary) assert result.eligible is False assert RejectionReason.LOW_TREND_STRENGTH in result.rejection_reasons def test_rejected_high_contradiction(): """Above max_contradiction_score → rejected.""" summary = _make_summary(contradiction_score=0.7) result = evaluate_eligibility(summary) assert result.eligible is False assert RejectionReason.HIGH_CONTRADICTION in result.rejection_reasons def test_rejected_insufficient_evidence(): """Too few evidence documents → rejected.""" summary = _make_summary( top_supporting_evidence=["doc1"], top_opposing_evidence=[], ) result = evaluate_eligibility(summary) assert result.eligible is False assert RejectionReason.INSUFFICIENT_EVIDENCE in result.rejection_reasons def test_rejected_neutral_direction(): """Neutral trend direction → rejected.""" summary = _make_summary(trend_direction=TrendDirection.NEUTRAL) result = evaluate_eligibility(summary) assert result.eligible is False assert RejectionReason.NEUTRAL_DIRECTION in result.rejection_reasons def test_rejected_forces_informational_mode(): """Any rejection forces mode to informational (Req 7.4).""" summary = _make_summary(confidence=0.2) result = evaluate_eligibility(summary) assert result.eligible is False assert result.mode == RecommendationMode.INFORMATIONAL # --------------------------------------------------------------------------- # Action mapping # --------------------------------------------------------------------------- def test_action_buy_strong_bullish(): summary = _make_summary( trend_direction=TrendDirection.BULLISH, trend_strength=0.4, ) result = evaluate_eligibility(summary) assert result.action == ActionType.BUY def test_action_sell_strong_bearish(): summary = _make_summary( trend_direction=TrendDirection.BEARISH, trend_strength=0.4, ) result = evaluate_eligibility(summary) assert result.action == ActionType.SELL def test_action_hold_weak_bullish_decent_confidence(): """Weak bullish with decent confidence → HOLD.""" summary = _make_summary( trend_direction=TrendDirection.BULLISH, trend_strength=0.15, confidence=0.55, ) result = evaluate_eligibility(summary) assert result.action == ActionType.HOLD def test_action_watch_weak_bullish_low_confidence(): """Weak bullish with low confidence → WATCH.""" summary = _make_summary( trend_direction=TrendDirection.BULLISH, trend_strength=0.15, confidence=0.40, ) result = evaluate_eligibility(summary) assert result.action == ActionType.WATCH def test_action_watch_mixed(): summary = _make_summary(trend_direction=TrendDirection.MIXED) result = evaluate_eligibility(summary) assert result.action == ActionType.WATCH # --------------------------------------------------------------------------- # Mode escalation # --------------------------------------------------------------------------- def test_mode_informational_for_hold(): """HOLD actions are always informational.""" summary = _make_summary( trend_direction=TrendDirection.BULLISH, trend_strength=0.15, confidence=0.55, ) result = evaluate_eligibility(summary) assert result.action == ActionType.HOLD assert result.mode == RecommendationMode.INFORMATIONAL def test_mode_paper_eligible(): """BUY with confidence >= paper threshold → paper_eligible.""" summary = _make_summary( trend_strength=0.4, confidence=0.55, contradiction_score=0.1, ) result = evaluate_eligibility(summary) assert result.action == ActionType.BUY assert result.mode == RecommendationMode.PAPER_ELIGIBLE def test_mode_live_eligible(): """BUY with high confidence, low contradiction, enough evidence → live_eligible.""" summary = _make_summary( trend_strength=0.5, confidence=0.75, contradiction_score=0.1, top_supporting_evidence=["d1", "d2", "d3", "d4"], top_opposing_evidence=["d5"], ) result = evaluate_eligibility(summary) assert result.action == ActionType.BUY assert result.mode == RecommendationMode.LIVE_ELIGIBLE def test_mode_not_live_high_contradiction(): """High contradiction blocks live even with high confidence.""" summary = _make_summary( trend_strength=0.5, confidence=0.75, contradiction_score=0.4, top_supporting_evidence=["d1", "d2", "d3", "d4", "d5"], top_opposing_evidence=[], ) result = evaluate_eligibility(summary) assert result.mode != RecommendationMode.LIVE_ELIGIBLE def test_mode_informational_low_confidence_buy(): """BUY with confidence below paper threshold → informational.""" summary = _make_summary( trend_strength=0.4, confidence=0.40, ) result = evaluate_eligibility(summary) assert result.action == ActionType.BUY assert result.mode == RecommendationMode.INFORMATIONAL # --------------------------------------------------------------------------- # Position sizing # --------------------------------------------------------------------------- def test_position_sizing_scales_with_confidence(): """Higher confidence → larger portfolio allocation.""" low = _make_summary(confidence=0.40, trend_strength=0.4) high = _make_summary(confidence=0.80, trend_strength=0.4) r_low = evaluate_eligibility(low) r_high = evaluate_eligibility(high) assert r_high.position_sizing.portfolio_pct > r_low.position_sizing.portfolio_pct def test_position_sizing_penalised_by_contradiction(): """Higher contradiction → smaller portfolio allocation.""" clean = _make_summary(contradiction_score=0.05, trend_strength=0.4) messy = _make_summary(contradiction_score=0.50, trend_strength=0.4) r_clean = evaluate_eligibility(clean) r_messy = evaluate_eligibility(messy) assert r_clean.position_sizing.portfolio_pct > r_messy.position_sizing.portfolio_pct def test_position_sizing_within_bounds(): """Sizing should always stay within configured bounds.""" cfg = DEFAULT_ELIGIBILITY_CONFIG for conf in [0.35, 0.5, 0.7, 0.9]: for contra in [0.0, 0.3, 0.55]: summary = _make_summary(confidence=conf, contradiction_score=contra, trend_strength=0.4) result = evaluate_eligibility(summary) assert result.position_sizing.portfolio_pct >= cfg.base_portfolio_pct * 0.5 assert result.position_sizing.portfolio_pct <= cfg.max_portfolio_pct assert result.position_sizing.max_loss_pct >= cfg.base_max_loss_pct * 0.5 assert result.position_sizing.max_loss_pct <= cfg.max_max_loss_pct # --------------------------------------------------------------------------- # Time horizon and invalidation # --------------------------------------------------------------------------- def test_time_horizon_mapped(): summary = _make_summary(window=TrendWindow.SEVEN_DAY) result = evaluate_eligibility(summary) assert result.time_horizon == "swing_1d_10d" def test_invalidation_conditions_present(): summary = _make_summary() result = evaluate_eligibility(summary) assert len(result.invalidation_conditions) > 0 assert any("AAPL" in c for c in result.invalidation_conditions) # --------------------------------------------------------------------------- # Custom config # --------------------------------------------------------------------------- def test_custom_config_stricter_gates(): """A stricter config rejects what the default would accept.""" strict = EligibilityConfig(min_confidence=0.80) summary = _make_summary(confidence=0.60) result = evaluate_eligibility(summary, config=strict) assert result.eligible is False assert RejectionReason.LOW_CONFIDENCE in result.rejection_reasons