"""Unit tests for services.signal_engine.heuristic — Heuristic Pipeline verdict logic. Tests BUY, WATCH, and SKIP verdict conditions, threshold edge cases, confidence computation (agreement boosts, contradiction penalties), S_total computation from multiple signals, and None-valued inputs. Requirements: 5.4, 5.5, 5.6 """ from __future__ import annotations from datetime import datetime, timezone from services.signal_engine.config import HeuristicConfig from services.signal_engine.heuristic import ( _compute_confidence, _compute_s_company, _compute_s_competitive, _compute_s_macro, _determine_verdict, run_heuristic_pipeline, ) from services.signal_engine.models import ( ConfluenceSignal, NormalizedInput, SignalDirection, Verdict, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _NOW = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) def _default_config() -> HeuristicConfig: """Return default heuristic config matching design thresholds.""" return HeuristicConfig() def _normalized( *, valuation_score: float | None = 0.8, earnings_proximity_days: int | None = 30, macro_bias: float = 0.5, ) -> NormalizedInput: """Create a NormalizedInput with sensible defaults for testing.""" return NormalizedInput( ticker="AAPL", evaluated_at=_NOW, bars={}, valuation_score=valuation_score, earnings_proximity_days=earnings_proximity_days, macro_bias=macro_bias, ) def _bullish_signal( signal_type: str = "fibonacci", confluence_score: float = 0.8, timeframes: list[str] | None = None, ) -> ConfluenceSignal: """Create a bullish confluence signal.""" tfs = timeframes or ["D", "W"] return ConfluenceSignal( signal_type=signal_type, direction=SignalDirection.BULLISH, confluence_score=confluence_score, active_timeframes=tfs, per_timeframe={tf: confluence_score for tf in tfs}, ) def _bearish_signal( signal_type: str = "rsi", confluence_score: float = 0.6, timeframes: list[str] | None = None, ) -> ConfluenceSignal: """Create a bearish confluence signal.""" tfs = timeframes or ["D", "H4"] return ConfluenceSignal( signal_type=signal_type, direction=SignalDirection.BEARISH, confluence_score=confluence_score, active_timeframes=tfs, per_timeframe={tf: confluence_score for tf in tfs}, ) def _neutral_signal( signal_type: str = "elliott_wave", confluence_score: float = 0.5, ) -> ConfluenceSignal: """Create a neutral confluence signal.""" return ConfluenceSignal( signal_type=signal_type, direction=SignalDirection.NEUTRAL, confluence_score=confluence_score, active_timeframes=["D", "W"], per_timeframe={"D": confluence_score, "W": confluence_score}, ) # =========================================================================== # 1. BUY verdict — all conditions met (Requirement 5.4) # =========================================================================== class TestBuyVerdict: """BUY requires confidence >= 0.70, S_total >= 1.2, valuation >= 0.5, macro_bias > 0, earnings_proximity_days > 5.""" def test_buy_all_conditions_met(self) -> None: """Strong bullish signals + favorable fundamentals → BUY.""" signals = [ _bullish_signal("fibonacci", 0.9), _bullish_signal("ma_stack", 0.85), _bullish_signal("rsi", 0.8), ] normalized = _normalized( valuation_score=0.7, earnings_proximity_days=30, macro_bias=0.5, ) config = _default_config() result = run_heuristic_pipeline(normalized, signals, config) assert result.verdict == Verdict.BUY assert result.confidence >= config.buy_confidence assert result.s_total >= config.buy_s_total assert len(result.reasoning) > 0 assert "BUY" in result.reasoning[0] def test_buy_reasoning_includes_all_values(self) -> None: """BUY reasoning should mention confidence, S_total, valuation, macro, earnings.""" signals = [ _bullish_signal("fibonacci", 0.9), _bullish_signal("ma_stack", 0.85), _bullish_signal("rsi", 0.8), ] normalized = _normalized(valuation_score=0.7, macro_bias=0.5, earnings_proximity_days=30) result = run_heuristic_pipeline(normalized, signals, _default_config()) assert result.verdict == Verdict.BUY reason = result.reasoning[0] assert "confidence" in reason.lower() assert "s_total" in reason.lower() def test_buy_s_total_components_populated(self) -> None: """BUY result should have non-zero s_company and s_macro.""" signals = [ _bullish_signal("fibonacci", 0.9), _bullish_signal("ma_stack", 0.85), _bullish_signal("rsi", 0.8), ] normalized = _normalized(macro_bias=0.5) result = run_heuristic_pipeline(normalized, signals, _default_config()) assert result.s_company > 0 assert result.s_macro > 0 # macro_bias=0.5 * 0.5 weight = 0.25 assert result.s_total == result.s_company + result.s_macro + result.s_competitive # =========================================================================== # 2. WATCH verdict — confidence sufficient but BUY conditions not fully met # (Requirement 5.5) # =========================================================================== class TestWatchVerdict: """WATCH: confidence >= 0.55 but at least one BUY condition fails.""" def test_watch_low_valuation(self) -> None: """Confidence OK but valuation below BUY threshold → WATCH.""" signals = [ _bullish_signal("fibonacci", 0.85), _bullish_signal("ma_stack", 0.80), ] normalized = _normalized( valuation_score=0.3, # below 0.5 BUY threshold macro_bias=0.5, earnings_proximity_days=30, ) result = run_heuristic_pipeline(normalized, signals, _default_config()) # Confidence should be >= watch threshold (0.55) with 2 strong bullish signals if result.confidence >= 0.55: assert result.verdict == Verdict.WATCH assert any("WATCH" in r for r in result.reasoning) def test_watch_negative_macro_bias(self) -> None: """Confidence OK but macro_bias <= 0 → WATCH (not BUY).""" signals = [ _bullish_signal("fibonacci", 0.85), _bullish_signal("ma_stack", 0.80), ] normalized = _normalized( valuation_score=0.8, macro_bias=-0.1, # negative, fails macro_bias > 0 earnings_proximity_days=30, ) result = run_heuristic_pipeline(normalized, signals, _default_config()) if result.confidence >= 0.55: assert result.verdict == Verdict.WATCH def test_watch_earnings_too_close(self) -> None: """Confidence OK but earnings within 5 days → WATCH.""" signals = [ _bullish_signal("fibonacci", 0.85), _bullish_signal("ma_stack", 0.80), ] normalized = _normalized( valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=3, # <= 5, fails earnings condition ) result = run_heuristic_pipeline(normalized, signals, _default_config()) if result.confidence >= 0.55: assert result.verdict == Verdict.WATCH def test_watch_macro_bias_exactly_zero(self) -> None: """macro_bias == 0 fails the > 0 condition → WATCH if confidence OK.""" signals = [ _bullish_signal("fibonacci", 0.85), _bullish_signal("ma_stack", 0.80), ] normalized = _normalized(macro_bias=0.0) result = run_heuristic_pipeline(normalized, signals, _default_config()) if result.confidence >= 0.55: assert result.verdict == Verdict.WATCH def test_watch_reasoning_lists_failed_conditions(self) -> None: """WATCH reasoning should identify which BUY conditions failed.""" signals = [ _bullish_signal("fibonacci", 0.85), _bullish_signal("ma_stack", 0.80), ] normalized = _normalized(valuation_score=0.3, macro_bias=0.0) result = run_heuristic_pipeline(normalized, signals, _default_config()) if result.verdict == Verdict.WATCH: full_reasoning = " ".join(result.reasoning) assert "valuation" in full_reasoning.lower() or "macro" in full_reasoning.lower() # =========================================================================== # 3. SKIP verdict — confidence below watch threshold (Requirement 5.6) # =========================================================================== class TestSkipVerdict: """SKIP: confidence < 0.55 (watch threshold).""" def test_skip_empty_signals(self) -> None: """No confluence signals → confidence = 0.0 → SKIP.""" normalized = _normalized() result = run_heuristic_pipeline(normalized, [], _default_config()) assert result.verdict == Verdict.SKIP assert result.confidence == 0.0 assert result.s_total == result.s_macro # only macro contributes def test_skip_single_weak_signal(self) -> None: """Single weak signal → low confidence → SKIP.""" signals = [_bullish_signal("fibonacci", 0.3)] normalized = _normalized() result = run_heuristic_pipeline(normalized, signals, _default_config()) # Single signal with score 0.3 → base_confidence=0.3, source_factor=0.6 # confidence = 0.3 * 0.6 * 1.0 = 0.18 → well below 0.55 assert result.verdict == Verdict.SKIP assert result.confidence < 0.55 def test_skip_reasoning_mentions_threshold(self) -> None: """SKIP reasoning should reference the watch threshold.""" result = run_heuristic_pipeline(_normalized(), [], _default_config()) assert result.verdict == Verdict.SKIP assert any("SKIP" in r for r in result.reasoning) # =========================================================================== # 4. Edge cases at threshold boundaries # =========================================================================== class TestThresholdEdgeCases: """Test behavior at exact threshold values.""" def test_confidence_exactly_at_buy_threshold(self) -> None: """Verify _determine_verdict with confidence exactly at 0.70.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, reasoning = _determine_verdict( confidence=0.70, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.BUY def test_confidence_just_below_buy_threshold(self) -> None: """confidence = 0.699 → not BUY, should be WATCH.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.699, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_confidence_exactly_at_watch_threshold(self) -> None: """confidence = 0.55 → WATCH (not SKIP).""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.55, s_total=0.5, # below BUY s_total threshold normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_confidence_just_below_watch_threshold(self) -> None: """confidence = 0.549 → SKIP.""" config = _default_config() normalized = _normalized() verdict, _ = _determine_verdict( confidence=0.549, s_total=2.0, normalized=normalized, config=config, ) assert verdict == Verdict.SKIP def test_s_total_exactly_at_buy_threshold(self) -> None: """S_total = 1.2 exactly → BUY if all other conditions met.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.2, normalized=normalized, config=config, ) assert verdict == Verdict.BUY def test_s_total_just_below_buy_threshold(self) -> None: """S_total = 1.199 → not BUY, should be WATCH.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.199, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_valuation_exactly_at_buy_threshold(self) -> None: """valuation_score = 0.5 exactly → BUY if all other conditions met.""" config = _default_config() normalized = _normalized(valuation_score=0.5, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.BUY def test_valuation_just_below_buy_threshold(self) -> None: """valuation_score = 0.499 → not BUY.""" config = _default_config() normalized = _normalized(valuation_score=0.499, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_earnings_exactly_at_threshold(self) -> None: """earnings_proximity_days = 5 → fails > 5 condition → WATCH.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=5) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_earnings_just_above_threshold(self) -> None: """earnings_proximity_days = 6 → passes > 5 condition → BUY.""" config = _default_config() normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=6) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.BUY def test_none_valuation_score_treated_as_zero(self) -> None: """None valuation_score defaults to 0.0 → fails BUY valuation check.""" config = _default_config() normalized = _normalized(valuation_score=None, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH def test_none_earnings_proximity_treated_as_zero(self) -> None: """None earnings_proximity_days defaults to 0 → fails BUY earnings check.""" config = _default_config() normalized = _normalized( valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=None, ) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH # =========================================================================== # 5. Signal agreement boosts confidence # =========================================================================== class TestConfidenceAgreement: """All signals in the same direction → agreement boost (factor 1.15).""" def test_all_bullish_signals_boost_confidence(self) -> None: """All bullish signals → agreement_factor = 1.15.""" signals = [ _bullish_signal("fibonacci", 0.7), _bullish_signal("ma_stack", 0.7), _bullish_signal("rsi", 0.7), ] confidence = _compute_confidence(signals) # base = 0.7, source_factor = 1 - 0.4/3 ≈ 0.867, agreement = 1.15 # confidence = 0.7 * 0.867 * 1.15 ≈ 0.698 assert confidence > 0.0 # Compare with a mixed-direction set to verify boost mixed_signals = [ _bullish_signal("fibonacci", 0.7), _bearish_signal("rsi", 0.7), _bullish_signal("ma_stack", 0.7), ] mixed_confidence = _compute_confidence(mixed_signals) assert confidence > mixed_confidence def test_all_bearish_signals_boost_confidence(self) -> None: """All bearish signals → agreement_factor = 1.15 (same boost).""" signals = [ _bearish_signal("fibonacci", 0.7), _bearish_signal("ma_stack", 0.7), ] confidence = _compute_confidence(signals) # base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.15 # confidence = 0.7 * 0.8 * 1.15 = 0.644 assert confidence > 0.6 def test_single_signal_no_agreement_factor(self) -> None: """Single signal → agreement_factor = 1.0 (no boost or penalty).""" signals = [_bullish_signal("fibonacci", 0.8)] confidence = _compute_confidence(signals) # base = 0.8, source_factor = 1 - 0.4/1 = 0.6, agreement = 1.0 # confidence = 0.8 * 0.6 * 1.0 = 0.48 assert abs(confidence - 0.48) < 0.001 def test_directional_plus_neutral_mild_boost(self) -> None: """Mix of directional and neutral signals → agreement_factor = 1.05.""" signals = [ _bullish_signal("fibonacci", 0.7), _neutral_signal("elliott_wave", 0.7), ] confidence = _compute_confidence(signals) # base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.05 # confidence = 0.7 * 0.8 * 1.05 = 0.588 assert abs(confidence - 0.588) < 0.001 # =========================================================================== # 6. Contradicting signals reduce confidence # =========================================================================== class TestConfidenceContradiction: """Mixed bullish/bearish signals → contradiction penalty.""" def test_contradiction_reduces_confidence(self) -> None: """Bullish + bearish signals → penalty proportional to minority fraction.""" contradicting = [ _bullish_signal("fibonacci", 0.7), _bearish_signal("rsi", 0.7), ] agreeing = [ _bullish_signal("fibonacci", 0.7), _bullish_signal("rsi", 0.7), ] conf_contradicting = _compute_confidence(contradicting) conf_agreeing = _compute_confidence(agreeing) assert conf_contradicting < conf_agreeing def test_more_contradiction_more_penalty(self) -> None: """Higher minority fraction → larger penalty.""" # 1 bearish out of 3 → minority = 1/3 mild_contradiction = [ _bullish_signal("fibonacci", 0.7), _bullish_signal("ma_stack", 0.7), _bearish_signal("rsi", 0.7), ] # 2 bearish out of 4 → minority = 2/4 = 0.5 strong_contradiction = [ _bullish_signal("fibonacci", 0.7), _bullish_signal("ma_stack", 0.7), _bearish_signal("rsi", 0.7), _bearish_signal("elliott_wave", 0.7), ] conf_mild = _compute_confidence(mild_contradiction) conf_strong = _compute_confidence(strong_contradiction) # Strong contradiction should have lower confidence per-signal # (accounting for source count factor difference) # mild: agreement = 1 - 0.3*(1/3) = 0.9 # strong: agreement = 1 - 0.3*(2/4) = 0.85 # The agreement factor is lower for strong contradiction mild_agreement = 1.0 - 0.3 * (1 / 3) strong_agreement = 1.0 - 0.3 * (2 / 4) assert strong_agreement < mild_agreement def test_equal_split_maximum_penalty(self) -> None: """50/50 split → maximum contradiction penalty (0.3 * 0.5 = 0.15).""" signals = [ _bullish_signal("fibonacci", 0.7), _bearish_signal("rsi", 0.7), ] confidence = _compute_confidence(signals) # base = 0.7, source_factor = 0.8, agreement = 1 - 0.3*0.5 = 0.85 # confidence = 0.7 * 0.8 * 0.85 = 0.476 assert abs(confidence - 0.476) < 0.001 # =========================================================================== # 7. Empty confluence signals → SKIP # =========================================================================== class TestEmptySignals: """Empty confluence signals produce confidence = 0 → SKIP.""" def test_empty_signals_confidence_zero(self) -> None: """No signals → confidence = 0.0.""" assert _compute_confidence([]) == 0.0 def test_empty_signals_skip_verdict(self) -> None: """No signals → SKIP regardless of fundamentals.""" normalized = _normalized(valuation_score=1.0, macro_bias=1.0, earnings_proximity_days=100) result = run_heuristic_pipeline(normalized, [], _default_config()) assert result.verdict == Verdict.SKIP assert result.confidence == 0.0 def test_empty_signals_s_total_only_macro(self) -> None: """No signals → S_company = 0, S_competitive = 0, S_total = S_macro only.""" normalized = _normalized(macro_bias=0.6) result = run_heuristic_pipeline(normalized, [], _default_config()) assert result.s_company == 0.0 assert result.s_competitive == 0.0 assert result.s_macro == 0.6 * 0.5 # macro_bias * _MACRO_WEIGHT assert result.s_total == result.s_macro # =========================================================================== # 8. S_total computation from multiple signals # =========================================================================== class TestSTotalComputation: """S_total = S_company + S_macro + S_competitive.""" def test_s_company_sums_company_signals(self) -> None: """Company-level signals (fibonacci, ma_stack, rsi) contribute to S_company.""" signals = [ _bullish_signal("fibonacci", 0.5), _bullish_signal("ma_stack", 0.3), ] s_company, weights = _compute_s_company(signals) # Both bullish → positive contributions assert s_company == 0.5 + 0.3 assert len(weights) == 2 assert all(w["layer"] == "company" for w in weights) def test_s_company_bearish_signals_subtract(self) -> None: """Bearish company signals contribute negatively to S_company.""" signals = [ _bullish_signal("fibonacci", 0.5), _bearish_signal("rsi", 0.3), ] s_company, weights = _compute_s_company(signals) # fibonacci: +0.5, rsi: -0.3 assert abs(s_company - 0.2) < 0.001 def test_s_company_neutral_signals_zero_contribution(self) -> None: """Neutral company signals contribute 0 to S_company.""" signals = [ ConfluenceSignal( signal_type="fibonacci", direction=SignalDirection.NEUTRAL, confluence_score=0.8, active_timeframes=["D", "W"], per_timeframe={"D": 0.8, "W": 0.8}, ), ] s_company, _ = _compute_s_company(signals) assert s_company == 0.0 def test_s_company_ignores_non_company_signals(self) -> None: """Signals not in COMPANY_SIGNAL_TYPES are ignored for S_company.""" signals = [ _bullish_signal("unknown_signal_type", 0.9), ] s_company, weights = _compute_s_company(signals) assert s_company == 0.0 assert len(weights) == 0 def test_s_macro_positive_bias(self) -> None: """Positive macro_bias → positive S_macro.""" normalized = _normalized(macro_bias=0.8) s_macro = _compute_s_macro(normalized) assert s_macro == 0.8 * 0.5 # macro_bias * _MACRO_WEIGHT def test_s_macro_negative_bias(self) -> None: """Negative macro_bias → negative S_macro.""" normalized = _normalized(macro_bias=-0.6) s_macro = _compute_s_macro(normalized) assert s_macro == -0.6 * 0.5 def test_s_macro_zero_bias(self) -> None: """Zero macro_bias → zero S_macro.""" normalized = _normalized(macro_bias=0.0) s_macro = _compute_s_macro(normalized) assert s_macro == 0.0 def test_s_competitive_currently_zero(self) -> None: """No competitive signal types defined → S_competitive = 0.""" signals = [_bullish_signal("fibonacci", 0.9)] s_competitive = _compute_s_competitive(signals) assert s_competitive == 0.0 def test_s_total_is_sum_of_components(self) -> None: """S_total = S_company + S_macro + S_competitive.""" signals = [ _bullish_signal("fibonacci", 0.5), _bullish_signal("ma_stack", 0.4), ] normalized = _normalized(macro_bias=0.6) result = run_heuristic_pipeline(normalized, signals, _default_config()) expected_s_company = 0.5 + 0.4 expected_s_macro = 0.6 * 0.5 expected_s_competitive = 0.0 expected_s_total = expected_s_company + expected_s_macro + expected_s_competitive assert abs(result.s_company - expected_s_company) < 0.001 assert abs(result.s_macro - expected_s_macro) < 0.001 assert abs(result.s_competitive - expected_s_competitive) < 0.001 assert abs(result.s_total - expected_s_total) < 0.001 def test_signal_weights_audit_trail(self) -> None: """signal_weights list contains per-signal audit info.""" signals = [ _bullish_signal("fibonacci", 0.5), _bullish_signal("rsi", 0.3), ] result = run_heuristic_pipeline(_normalized(), signals, _default_config()) assert len(result.signal_weights) == 2 types = {w["signal_type"] for w in result.signal_weights} assert "fibonacci" in types assert "rsi" in types for w in result.signal_weights: assert "contribution" in w assert "direction" in w assert "active_timeframes" in w # =========================================================================== # 9. Full pipeline integration — HeuristicResult structure # =========================================================================== class TestHeuristicResultStructure: """Verify the HeuristicResult has all required fields.""" def test_result_has_all_fields(self) -> None: """HeuristicResult contains verdict, confidence, scores, weights, reasoning.""" signals = [_bullish_signal("fibonacci", 0.7)] result = run_heuristic_pipeline(_normalized(), signals, _default_config()) assert result.verdict in (Verdict.BUY, Verdict.WATCH, Verdict.SKIP) assert 0.0 <= result.confidence <= 1.0 assert isinstance(result.s_total, float) assert isinstance(result.s_company, float) assert isinstance(result.s_macro, float) assert isinstance(result.s_competitive, float) assert isinstance(result.signal_weights, list) assert isinstance(result.reasoning, list) assert len(result.reasoning) > 0 def test_confidence_clamped_to_unit_interval(self) -> None: """Confidence is always in [0.0, 1.0] even with strong agreement boost.""" # Very high confluence scores with perfect agreement signals = [ _bullish_signal("fibonacci", 1.0), _bullish_signal("ma_stack", 1.0), _bullish_signal("rsi", 1.0), _bullish_signal("cup_handle", 1.0), _bullish_signal("elliott_wave", 1.0), ] confidence = _compute_confidence(signals) assert 0.0 <= confidence <= 1.0 # =========================================================================== # 10. Custom config thresholds # =========================================================================== class TestCustomConfig: """Verify that custom config thresholds are respected.""" def test_custom_buy_confidence_threshold(self) -> None: """Lowering buy_confidence makes BUY easier to achieve.""" config = HeuristicConfig(buy_confidence=0.50, buy_s_total=0.5) normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30) verdict, _ = _determine_verdict( confidence=0.55, s_total=0.8, normalized=normalized, config=config, ) assert verdict == Verdict.BUY def test_custom_watch_confidence_threshold(self) -> None: """Raising watch_confidence makes WATCH harder to achieve.""" config = HeuristicConfig(watch_confidence=0.80) normalized = _normalized() verdict, _ = _determine_verdict( confidence=0.75, s_total=0.5, normalized=normalized, config=config, ) assert verdict == Verdict.SKIP # 0.75 < 0.80 watch threshold def test_custom_earnings_threshold(self) -> None: """Custom earnings_days_threshold changes BUY gating.""" config = HeuristicConfig(earnings_days_threshold=10) normalized = _normalized( valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=8, # > 5 default but <= 10 custom ) verdict, _ = _determine_verdict( confidence=0.80, s_total=1.5, normalized=normalized, config=config, ) assert verdict == Verdict.WATCH # 8 is not > 10