"""Unit tests for libs.backtest.scoring.""" from __future__ import annotations import pytest from libs.backtest.scoring import ( _close_strength_score, _compute_management_change_score, _direction_clarity_score, _earnings_surprise_score, _event_quality_score, _gap_score, _parse_confidence_score, _reaction_score, _risk_penalty_score, _text_sentiment_score, _volume_score, compute_entry_score, compute_microstructure_score, compute_patient_drift_score, compute_return_max_long_score, compute_return_max_long_score_v2, compute_return_max_long_score_v3, compute_return_max_long_score_v4, compute_return_max_long_score_v5, compute_return_max_long_score_v11, compute_return_max_long_score_v11g, compute_return_max_long_score_v18, compute_return_max_long_score_v18b, compute_return_max_long_score_v18c, compute_return_max_long_score_v18d, compute_return_max_long_score_ml_v2, compute_return_max_long_score_ml_v2_h1, compute_return_max_long_score_ml_v2_h2, compute_return_max_long_score_ml_v2_h3, compute_return_max_long_score_ml_v3, compute_return_max_long_score_ml_v3_h1, compute_return_max_long_score_ml_v3_h2, compute_return_max_long_score_ml_v3_h3, ) class TestReactionScore: """Reaction day return scoring — simple directional mapping.""" def test_strong_positive(self): """Strong positive return (>3%).""" assert _reaction_score({"reaction_day_return": 0.05}) == 0.7 assert _reaction_score({"reaction_day_return": 0.15}) == 0.7 def test_positive(self): """Positive return (0-3%).""" assert _reaction_score({"reaction_day_return": 0.01}) == 0.6 assert _reaction_score({"reaction_day_return": 0.025}) == 0.6 assert _reaction_score({"reaction_day_return": 0.002}) == 0.6 def test_flat_to_mild_negative(self): """Flat to mild negative (0% to -2%).""" assert _reaction_score({"reaction_day_return": -0.01}) == 0.45 def test_moderate_negative(self): """Moderate negative (-5% to -2%).""" assert _reaction_score({"reaction_day_return": -0.03}) == 0.3 def test_strongly_bearish(self): """Strongly bearish (<-5%).""" assert _reaction_score({"reaction_day_return": -0.08}) == 0.2 def test_missing_returns_default(self): assert _reaction_score({}) == 0.5 class TestCloseStrengthScore: """Close location scoring.""" def test_near_high(self): score = _close_strength_score({"close_location": 0.9}) assert score == pytest.approx(0.91, abs=0.01) def test_near_low(self): score = _close_strength_score({"close_location": 0.1}) assert score == pytest.approx(0.19, abs=0.01) def test_midpoint(self): score = _close_strength_score({"close_location": 0.5}) assert score == pytest.approx(0.55, abs=0.01) def test_missing_returns_default(self): assert _close_strength_score({}) == 0.5 def test_clamped_above_1(self): """Values > 1.0 are clamped.""" score = _close_strength_score({"close_location": 1.5}) assert score == pytest.approx(1.0, abs=0.01) class TestVolumeScore: """Volume ratio scoring.""" def test_healthy_conviction(self): assert _volume_score({"volume_ratio_20d": 1.5}) == 0.8 def test_normal(self): assert _volume_score({"volume_ratio_20d": 1.1}) == 0.6 def test_high_possible_exhaustion(self): assert _volume_score({"volume_ratio_20d": 2.5}) == 0.55 def test_extreme_exhaustion(self): assert _volume_score({"volume_ratio_20d": 4.0}) == 0.4 def test_below_average(self): assert _volume_score({"volume_ratio_20d": 0.8}) == 0.3 def test_missing_returns_default(self): assert _volume_score({}) == 0.5 class TestGapScore: """Gap size scoring.""" def test_orderly_positive(self): assert _gap_score({"gap_size": 0.01}) == 0.8 def test_neutral(self): assert _gap_score({"gap_size": 0.002}) == 0.6 def test_extended(self): assert _gap_score({"gap_size": 0.03}) == 0.5 def test_exhaustion_gap(self): assert _gap_score({"gap_size": 0.07}) == 0.3 def test_mild_negative(self): assert _gap_score({"gap_size": -0.01}) == 0.4 def test_bearish_gap(self): assert _gap_score({"gap_size": -0.03}) == 0.2 def test_missing_returns_default(self): assert _gap_score({}) == 0.5 class TestComputeEntryScore: """Composite score tests.""" def test_all_missing_returns_neutral(self): """All features missing → all defaults at 0.5 → composite 0.5.""" score = compute_entry_score({}) assert score == pytest.approx(0.5, abs=0.02) def test_ideal_setup_scores_high(self): """Positive return + healthy volume (no event features → event=0.5). event=0.5*0.65 + reaction=0.6*0.20 + volume=0.8*0.15 ≈ 0.565 """ row = { "reaction_day_return": 0.01, # positive → 0.6 "volume_ratio_20d": 1.5, # conviction → 0.8 } score = compute_entry_score(row) assert score > 0.55 def test_bearish_setup_scores_low(self): """Negative return + below avg volume (no event features → event=0.5). event=0.5*0.65 + reaction=0.3*0.20 + volume=0.3*0.15 ≈ 0.43 """ row = { "reaction_day_return": -0.04, # moderate negative → 0.3 "volume_ratio_20d": 0.8, # no conviction → 0.3 } score = compute_entry_score(row) assert score < 0.45 def test_extreme_positive_penalized(self): """Very large positive reaction should be penalized.""" extreme = compute_entry_score({ "reaction_day_return": 0.15, # extreme → 0.2 "close_location": 0.7, "volume_ratio_20d": 3.5, # extreme volume → 0.4 "gap_size": 0.08, # exhaustion gap → 0.3 }) moderate = compute_entry_score({ "reaction_day_return": 0.015, # sweet spot → 0.9 "close_location": 0.7, "volume_ratio_20d": 1.5, # healthy → 0.8 "gap_size": 0.01, # orderly → 0.8 }) assert moderate > extreme def test_score_bounded_0_to_1(self): """Score is always in [0, 1].""" extremes = [ {"reaction_day_return": -0.5, "close_location": 0.0, "volume_ratio_20d": 0.1, "gap_size": -0.1}, {"reaction_day_return": 0.5, "close_location": 1.0, "volume_ratio_20d": 10.0, "gap_size": 0.2}, ] for row in extremes: score = compute_entry_score(row) assert 0.0 <= score <= 1.0 def test_aapl_like_scores_highest(self): """AAPL-like setup (moderate +, close near high) scores well.""" aapl = compute_entry_score({ "reaction_day_return": 0.007, "close_location": 0.74, "volume_ratio_20d": 1.45, "gap_size": 0.006, }) # DDOG-like: extreme positive, high volume ddog = compute_entry_score({ "reaction_day_return": 0.137, "close_location": 0.63, "volume_ratio_20d": 2.83, "gap_size": 0.089, }) assert aapl > ddog, f"AAPL-like {aapl:.3f} should beat DDOG-like {ddog:.3f}" def test_real_data_scores(self): """Verify scores for real data samples match expectations. With no event features, both get event=0.5. TSLA: 0.5*0.65 + 0.3*0.20 + 0.6*0.15 = 0.475 AAPL: 0.5*0.65 + 0.6*0.20 + 0.8*0.15 = 0.565 """ # TSLA 1/2: bearish (return -2.6%) tsla = compute_entry_score({ "reaction_day_return": -0.026, "volume_ratio_20d": 1.13, }) # AAPL 1/29: bullish (moderate +) aapl = compute_entry_score({ "reaction_day_return": 0.007, "volume_ratio_20d": 1.45, }) assert aapl > 0.55, f"AAPL should be above 0.55, got {aapl:.3f}" assert tsla < 0.49, f"TSLA should be below 0.49, got {tsla:.3f}" assert aapl > tsla class TestReturnMaxLongScoreMlV2: def test_missing_reaction_returns_zero(self): assert compute_return_max_long_score_ml_v2({}) == 0.0 def test_score_is_bounded(self): score = compute_return_max_long_score_ml_v2({ "event_type": "earnings_release", "reaction_day_return": 0.08, "gap_size": 0.03, "close_location": 0.3, "volume_ratio_20d": 3.5, "document_quality_score": 0.8, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.1, "market_cap_proxy": 20_000_000_000.0, "avg_dollar_volume_20d": 500_000_000.0, "pre_event_volatility_20d": 0.03, "earnings_surprise_pct": 8.0, }) assert 0.0 <= score <= 1.0 def test_strong_drift_setup_scores_above_weak_setup(self): strong = compute_return_max_long_score_ml_v2({ "event_type": "earnings_release", "reaction_day_return": 0.09, "gap_size": 0.015, "close_location": 0.35, "volume_ratio_20d": 3.0, "document_quality_score": 0.78, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 18_000_000_000.0, "avg_dollar_volume_20d": 700_000_000.0, "pre_event_entropy_60d": 1.6, "pre_event_gravitational_pull": 0.9, "pre_event_hurst_60d": 0.63, "pre_event_market_temperature": 0.8, "pre_event_ou_theta_60d": 0.03, "pre_event_volatility_20d": 0.04, "pre_event_rsi_14": 58.0, "pre_event_bb_position": 0.45, "pre_event_obv_slope_20d": 0.12, "macro_vix": 20.0, "macro_hy_spread": 3.5, "macro_t10y2y": 0.1, "prior_event_fwd5d": 0.03, "lm_positive_pct": 0.01, "lm_negative_pct": 0.001, "lm_net_sentiment": 0.01, "earnings_surprise_pct": 12.0, }) weak = compute_return_max_long_score_ml_v2({ "event_type": "earnings_release", "reaction_day_return": 0.01, "gap_size": 0.0, "close_location": 0.92, "volume_ratio_20d": 0.9, "document_quality_score": 0.45, "parse_confidence_overall": 0.4, "oneoff_penalty": 0.5, "market_cap_proxy": 400_000_000_000.0, "avg_dollar_volume_20d": 80_000_000.0, "pre_event_entropy_60d": 2.2, "pre_event_gravitational_pull": 4.0, "pre_event_hurst_60d": 0.48, "pre_event_market_temperature": 1.5, "pre_event_ou_theta_60d": 0.18, "pre_event_volatility_20d": 0.008, "pre_event_rsi_14": 49.0, "pre_event_bb_position": 0.95, "pre_event_obv_slope_20d": -0.2, "macro_vix": 13.0, "macro_hy_spread": 2.8, "macro_t10y2y": -0.4, "prior_event_fwd5d": -0.02, "lm_positive_pct": 0.0, "lm_negative_pct": 0.01, "lm_net_sentiment": -0.01, "earnings_surprise_pct": -5.0, }) assert strong > weak def test_hybrid_scores_keep_v13e_gate(self): row = { "event_type": "earnings_release", "event_direction": "bearish", "reaction_day_return": 0.09, "gap_size": 0.015, "close_location": 0.35, "volume_ratio_20d": 3.0, "document_quality_score": 0.78, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 18_000_000_000.0, "avg_dollar_volume_20d": 700_000_000.0, "pre_event_volatility_20d": 0.04, "earnings_surprise_pct": 12.0, } assert compute_return_max_long_score_ml_v2_h1(row) == 0.0 assert compute_return_max_long_score_ml_v2_h2(row) == 0.0 assert compute_return_max_long_score_ml_v2_h3(row) == 0.0 def test_hybrid_scores_reorder_eligible_rows(self): strong = { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.09, "gap_size": 0.015, "close_location": 0.35, "volume_ratio_20d": 3.0, "document_quality_score": 0.78, "signal_strength_score": 0.78, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 18_000_000_000.0, "avg_dollar_volume_20d": 700_000_000.0, "pre_event_entropy_60d": 1.6, "pre_event_gravitational_pull": 0.9, "pre_event_hurst_60d": 0.63, "pre_event_market_temperature": 0.8, "pre_event_ou_theta_60d": 0.03, "pre_event_volatility_20d": 0.04, "pre_event_rsi_14": 58.0, "pre_event_bb_position": 0.45, "pre_event_obv_slope_20d": 0.12, "macro_vix": 20.0, "macro_hy_spread": 3.5, "macro_t10y2y": 0.1, "prior_event_fwd5d": 0.03, "lm_positive_pct": 0.01, "lm_negative_pct": 0.001, "lm_net_sentiment": 0.01, "earnings_surprise_pct": 12.0, } weak = { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.05, "gap_size": 0.005, "close_location": 0.78, "volume_ratio_20d": 1.2, "document_quality_score": 0.70, "signal_strength_score": 0.70, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 180_000_000_000.0, "avg_dollar_volume_20d": 80_000_000.0, "pre_event_entropy_60d": 2.2, "pre_event_gravitational_pull": 4.0, "pre_event_hurst_60d": 0.48, "pre_event_market_temperature": 1.5, "pre_event_ou_theta_60d": 0.18, "pre_event_volatility_20d": 0.008, "pre_event_rsi_14": 49.0, "pre_event_bb_position": 0.95, "pre_event_obv_slope_20d": -0.2, "macro_vix": 13.0, "macro_hy_spread": 2.8, "macro_t10y2y": -0.4, "prior_event_fwd5d": -0.02, "lm_positive_pct": 0.0, "lm_negative_pct": 0.01, "lm_net_sentiment": -0.01, "earnings_surprise_pct": -5.0, } assert compute_return_max_long_score_ml_v2_h1(strong) > compute_return_max_long_score_ml_v2_h1(weak) assert compute_return_max_long_score_ml_v2_h2(strong) > compute_return_max_long_score_ml_v2_h2(weak) assert compute_return_max_long_score_ml_v2_h3(strong) > compute_return_max_long_score_ml_v2_h3(weak) class TestReturnMaxLongScoreMlV3: def test_missing_reaction_returns_zero(self): assert compute_return_max_long_score_ml_v3({}) == 0.0 def test_score_is_bounded(self): score = compute_return_max_long_score_ml_v3({ "event_type": "earnings_release", "reaction_day_return": 0.08, "gap_size": 0.03, "close_location": 0.3, "volume_ratio_20d": 3.5, "document_quality_score": 0.8, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.1, "market_cap_proxy": 20_000_000_000.0, "avg_dollar_volume_20d": 500_000_000.0, "pre_event_volatility_20d": 0.03, "earnings_surprise_pct": 8.0, "sue_hist_mean_4q": 6.0, "peer_sector_event_count_365d": 5.0, "peer_relative_surprise_pct_365d": 3.0, "peer_relative_sue_hist_mean_4q_365d": 1.2, }) assert 0.0 <= score <= 1.0 def test_peer_features_improve_scoring_for_better_setup(self): strong = { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.09, "gap_size": 0.015, "close_location": 0.35, "volume_ratio_20d": 3.0, "document_quality_score": 0.78, "signal_strength_score": 0.78, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 18_000_000_000.0, "avg_dollar_volume_20d": 700_000_000.0, "pre_event_entropy_60d": 1.6, "pre_event_gravitational_pull": 0.9, "pre_event_hurst_60d": 0.63, "pre_event_market_temperature": 0.8, "pre_event_ou_theta_60d": 0.03, "pre_event_volatility_20d": 0.04, "pre_event_rsi_14": 58.0, "pre_event_bb_position": 0.45, "pre_event_obv_slope_20d": 0.12, "macro_vix": 20.0, "macro_hy_spread": 3.5, "macro_t10y2y": 0.1, "prior_event_fwd5d": 0.03, "lm_positive_pct": 0.01, "lm_negative_pct": 0.001, "lm_net_sentiment": 0.01, "earnings_surprise_pct": 12.0, "sue_lag_1_pct": 8.0, "sue_lag_2_pct": 7.0, "sue_lag_3_pct": 6.0, "sue_hist_mean_4q": 7.0, "sue_hist_pos_rate_4q": 1.0, "sue_hist_latest_pct": 8.0, "sue_hist_streak_pos": 3.0, "peer_sector_event_count_365d": 6.0, "peer_sector_surprise_median_365d": 5.0, "peer_sector_surprise_mean_365d": 5.5, "peer_sector_surprise_pos_rate_365d": 0.8, "peer_relative_surprise_pct_365d": 7.0, "peer_sector_sue_hist_mean_4q_median_365d": 4.0, "peer_sector_sue_hist_mean_4q_mean_365d": 4.5, "peer_relative_sue_hist_mean_4q_365d": 3.0, "peer_sector_sue_hist_pos_rate_4q_mean_365d": 0.7, } weak = { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.05, "gap_size": 0.005, "close_location": 0.78, "volume_ratio_20d": 1.2, "document_quality_score": 0.70, "signal_strength_score": 0.70, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "market_cap_proxy": 180_000_000_000.0, "avg_dollar_volume_20d": 80_000_000.0, "pre_event_entropy_60d": 2.2, "pre_event_gravitational_pull": 4.0, "pre_event_hurst_60d": 0.48, "pre_event_market_temperature": 1.5, "pre_event_ou_theta_60d": 0.18, "pre_event_volatility_20d": 0.008, "pre_event_rsi_14": 49.0, "pre_event_bb_position": 0.95, "pre_event_obv_slope_20d": -0.2, "macro_vix": 13.0, "macro_hy_spread": 2.8, "macro_t10y2y": -0.4, "prior_event_fwd5d": -0.02, "lm_positive_pct": 0.0, "lm_negative_pct": 0.01, "lm_net_sentiment": -0.01, "earnings_surprise_pct": -5.0, "sue_lag_1_pct": -2.0, "sue_lag_2_pct": 1.0, "sue_lag_3_pct": 0.5, "sue_hist_mean_4q": 1.0, "sue_hist_pos_rate_4q": 0.25, "sue_hist_latest_pct": -2.0, "sue_hist_streak_pos": 0.0, "peer_sector_event_count_365d": 12.0, "peer_sector_surprise_median_365d": 6.0, "peer_sector_surprise_mean_365d": 6.5, "peer_sector_surprise_pos_rate_365d": 0.9, "peer_relative_surprise_pct_365d": -11.0, "peer_sector_sue_hist_mean_4q_median_365d": 5.5, "peer_sector_sue_hist_mean_4q_mean_365d": 5.0, "peer_relative_sue_hist_mean_4q_365d": -4.5, "peer_sector_sue_hist_pos_rate_4q_mean_365d": 0.8, } assert compute_return_max_long_score_ml_v3(strong) > compute_return_max_long_score_ml_v3(weak) assert compute_return_max_long_score_ml_v3_h1(strong) > compute_return_max_long_score_ml_v3_h1(weak) assert compute_return_max_long_score_ml_v3_h2(strong) > compute_return_max_long_score_ml_v3_h2(weak) assert compute_return_max_long_score_ml_v3_h3(strong) > compute_return_max_long_score_ml_v3_h3(weak) def test_hybrid_scores_keep_v13e_gate(self): row = { "event_type": "earnings_release", "event_direction": "bearish", "reaction_day_return": 0.09, "gap_size": 0.015, "close_location": 0.35, "volume_ratio_20d": 3.0, "document_quality_score": 0.78, "parse_confidence_overall": 0.7, "oneoff_penalty": 0.05, "peer_relative_surprise_pct_365d": 4.0, } assert compute_return_max_long_score_ml_v3_h1(row) == 0.0 assert compute_return_max_long_score_ml_v3_h2(row) == 0.0 assert compute_return_max_long_score_ml_v3_h3(row) == 0.0 def test_event_features_boost_score(self): """Strong event features should boost overall score.""" market_only = compute_entry_score({ "reaction_day_return": 0.01, "close_location": 0.6, "volume_ratio_20d": 1.5, "gap_size": 0.01, }) with_events = compute_entry_score({ "reaction_day_return": 0.01, "close_location": 0.6, "volume_ratio_20d": 1.5, "gap_size": 0.01, "signal_strength_score": 0.8, "guidance_direction_score": 1.0, "document_quality_score": 0.7, "oneoff_penalty": 0.0, }) assert with_events > market_only def test_removed_features_dont_affect_composite(self): """Changing non-alpha features should not change composite score. eps_growth_qoq, oneoff_penalty, parse_confidence_overall, event_direction, and lm_net_sentiment are excluded from composite. """ base_row = { "reaction_day_return": 0.01, "close_location": 0.6, "volume_ratio_20d": 1.5, "gap_size": 0.01, } base_score = compute_entry_score(base_row) # Varying removed features should not change score for extra in [ {"eps_growth_qoq": 0.50}, {"oneoff_penalty": 1.0}, {"parse_confidence_overall": 0.9}, {"event_direction": "bullish"}, {"lm_net_sentiment": 0.01}, ]: row = {**base_row, **extra} assert compute_entry_score(row) == pytest.approx(base_score), ( f"Feature {list(extra.keys())[0]} should not affect composite" ) class TestComputeReturnMaxLongScore: def test_bullish_earnings_setup_scores_above_threshold(self): score = compute_return_max_long_score( { "event_type": "earnings_release", "event_direction": "bullish", "parse_confidence_overall": 0.85, "parse_confidence_event_direction": 0.80, "parse_confidence_guidance": 0.75, "guidance_status": "raised", "document_quality_score": 0.80, "guidance_direction_score": 1.0, "oneoff_penalty": 0.10, "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.9, "gap_size": 0.015, } ) assert score > 0.62 def test_non_bullish_direction_is_hard_rejected(self): score = compute_return_max_long_score( { "event_type": "earnings_release", "event_direction": "mixed", "parse_confidence_overall": 0.90, "oneoff_penalty": 0.10, "reaction_day_return": 0.08, "close_location": 0.80, "volume_ratio_20d": 2.0, "gap_size": 0.01, } ) assert score == 0.0 def test_overheat_penalty_reduces_score(self): cool = compute_return_max_long_score( { "event_type": "earnings_release", "event_direction": "bullish", "parse_confidence_overall": 0.85, "parse_confidence_event_direction": 0.80, "document_quality_score": 0.80, "guidance_direction_score": 0.5, "oneoff_penalty": 0.10, "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.9, "gap_size": 0.015, } ) hot = compute_return_max_long_score( { "event_type": "earnings_release", "event_direction": "bullish", "parse_confidence_overall": 0.85, "parse_confidence_event_direction": 0.80, "document_quality_score": 0.80, "guidance_direction_score": 0.5, "oneoff_penalty": 0.10, "reaction_day_return": 0.16, "close_location": 0.82, "volume_ratio_20d": 1.9, "gap_size": 0.06, "attention_wiki_spike_10d": 3.5, } ) assert cool > hot def test_missing_direction_confidence_falls_back_to_overall(self): score = compute_return_max_long_score( { "event_type": "earnings_release", "event_direction": "bullish", "parse_confidence_overall": 0.72, "document_quality_score": 0.80, "guidance_direction_score": 0.80, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.85, "volume_ratio_20d": 2.0, "gap_size": 0.01, } ) assert score > 0.62 def test_guidance_confidence_falls_back_to_overall(self): score = compute_return_max_long_score( { "event_type": "guidance_update", "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.78, "document_quality_score": 0.75, "guidance_direction_score": 1.0, "oneoff_penalty": 0.05, "reaction_day_return": 0.06, "close_location": 0.80, "volume_ratio_20d": 1.8, "gap_size": 0.01, } ) assert score > 0.62 def test_v2_allows_mixed_earnings_when_reaction_is_positive(self): score = compute_return_max_long_score_v2( { "event_type": "earnings_release", "event_direction": "mixed", "parse_confidence_overall": 0.72, "document_quality_score": 0.70, "signal_strength_score": 0.76, "guidance_direction_score": 0.50, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, } ) assert score > 0.60 def test_v2_still_rejects_bearish_earnings(self): score = compute_return_max_long_score_v2( { "event_type": "earnings_release", "event_direction": "bearish", "parse_confidence_overall": 0.72, "document_quality_score": 0.70, "signal_strength_score": 0.76, "guidance_direction_score": 0.50, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, } ) assert score == 0.0 def test_v3_allows_mixed_earnings_when_reaction_is_positive(self): score = compute_return_max_long_score_v3( { "event_type": "earnings_release", "event_direction": "mixed", "parse_confidence_overall": 0.72, "document_quality_score": 0.70, "signal_strength_score": 0.76, "guidance_direction_score": 0.50, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, } ) assert score > 0.60 def test_v3_still_rejects_bearish_earnings(self): score = compute_return_max_long_score_v3( { "event_type": "earnings_release", "event_direction": "bearish", "parse_confidence_overall": 0.72, "document_quality_score": 0.70, "signal_strength_score": 0.76, "guidance_direction_score": 0.50, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, } ) assert score == 0.0 def test_v3_weights_market_confirmation_more_than_v2(self): row = { "event_type": "earnings_release", "event_direction": "mixed", "parse_confidence_overall": 0.70, "document_quality_score": 0.56, "signal_strength_score": 0.58, "guidance_direction_score": 0.25, "oneoff_penalty": 0.05, "reaction_day_return": 0.11, "close_location": 0.92, "volume_ratio_20d": 2.2, "gap_size": 0.01, } assert compute_return_max_long_score_v3(row) > compute_return_max_long_score_v2(row) class TestManagementChangeScore: """Management change scoring — bullish reaction + close_location weighted.""" def _mc_row(self, **overrides): base = { "event_type": "management_change", "reaction_day_return": 0.015, "parse_confidence_overall": 0.55, "oneoff_penalty": 0.10, "document_quality_score": 0.60, "close_location": 0.65, "volume_ratio_20d": 1.1, "gap_size": 0.005, } base.update(overrides) return base def test_good_mc_scores_above_zero(self): score = _compute_management_change_score(self._mc_row()) assert score > 0.40 def test_bearish_reaction_rejected(self): assert _compute_management_change_score(self._mc_row(reaction_day_return=-0.02)) == 0.0 def test_zero_reaction_rejected(self): assert _compute_management_change_score(self._mc_row(reaction_day_return=0.0)) == 0.0 def test_low_parse_confidence_rejected(self): assert _compute_management_change_score(self._mc_row(parse_confidence_overall=0.40)) == 0.0 def test_high_oneoff_penalty_rejected(self): assert _compute_management_change_score(self._mc_row(oneoff_penalty=0.45)) == 0.0 def test_missing_parse_confidence_rejected(self): assert _compute_management_change_score(self._mc_row(parse_confidence_overall=None)) == 0.0 def test_higher_close_location_scores_higher(self): low = _compute_management_change_score(self._mc_row(close_location=0.40)) high = _compute_management_change_score(self._mc_row(close_location=0.80)) assert high > low def test_score_bounded_0_to_1(self): extremes = [ self._mc_row(reaction_day_return=0.001, close_location=0.1, document_quality_score=0.1), self._mc_row(reaction_day_return=0.15, close_location=1.0, document_quality_score=1.0), ] for row in extremes: score = _compute_management_change_score(row) assert 0.0 <= score <= 1.0 def test_routed_via_return_max_long_score(self): """management_change events are routed through the return_max_long_v2 scorer.""" score = compute_return_max_long_score_v2(self._mc_row()) assert score > 0.0 def test_non_mc_event_type_still_zero(self): """Other unsupported event types still return 0.""" score = compute_return_max_long_score_v2( self._mc_row(event_type="other_material_event") ) assert score == 0.0 class TestReturnMaxLongV5MaterialScore: """V5 should keep earnings semantics and add generic material-event scoring.""" def _material_row(self, **overrides): base = { "event_type": "material_contract", "event_direction": "unknown", "parse_confidence_overall": 0.58, "document_quality_score": 0.62, "signal_strength_score": 0.66, "oneoff_penalty": 0.12, "reaction_day_return": 0.06, "close_location": 0.78, "volume_ratio_20d": 1.12, "gap_size": 0.01, } base.update(overrides) return base def test_v5_matches_v2_on_earnings(self): row = { "event_type": "earnings_release", "event_direction": "mixed", "parse_confidence_overall": 0.72, "document_quality_score": 0.70, "signal_strength_score": 0.76, "guidance_direction_score": 0.50, "oneoff_penalty": 0.05, "reaction_day_return": 0.07, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, } assert compute_return_max_long_score_v5(row) == pytest.approx( compute_return_max_long_score_v2(row) ) def test_v5_scores_good_material_contract_above_zero(self): score = compute_return_max_long_score_v5(self._material_row()) assert score > 0.55 def test_v5_scores_good_other_material_event_above_zero(self): score = compute_return_max_long_score_v5( self._material_row( event_type="other_material_event", event_direction="mixed", reaction_day_return=0.03, close_location=0.68, volume_ratio_20d=1.25, gap_size=-0.005, ) ) assert score > 0.45 def test_v5_rejects_high_oneoff_material_event(self): assert compute_return_max_long_score_v5( self._material_row(oneoff_penalty=0.45) ) == 0.0 def test_v4_alias_matches_v5(self): row = self._material_row() assert compute_return_max_long_score_v4(row) == pytest.approx( compute_return_max_long_score_v5(row) ) class TestPatientDriftScore: """Patient drift scoring — market-only features, no NLP.""" def _pd_row(self, **overrides): base = { "event_type": "earnings_release", "reaction_day_return": 0.06, "parse_confidence_overall": 0.70, "oneoff_penalty": 0.10, "close_location": 0.75, "volume_ratio_20d": 1.8, "gap_size": 0.01, } base.update(overrides) return base def test_good_setup_scores_above_threshold(self): score = compute_patient_drift_score(self._pd_row()) assert score > 0.35 def test_score_bounded_0_to_1(self): extremes = [ self._pd_row(reaction_day_return=0.001, close_location=0.1, volume_ratio_20d=0.5), self._pd_row(reaction_day_return=0.20, close_location=1.0, volume_ratio_20d=5.0), ] for row in extremes: score = compute_patient_drift_score(row) assert 0.0 <= score <= 1.0 def test_rejects_non_earnings_guidance(self): assert compute_patient_drift_score(self._pd_row(event_type="management_change")) == 0.0 assert compute_patient_drift_score(self._pd_row(event_type="material_contract")) == 0.0 def test_accepts_guidance_update(self): score = compute_patient_drift_score(self._pd_row(event_type="guidance_update")) assert score > 0.0 def test_rejects_negative_reaction(self): assert compute_patient_drift_score(self._pd_row(reaction_day_return=-0.02)) == 0.0 assert compute_patient_drift_score(self._pd_row(reaction_day_return=0.0)) == 0.0 def test_rejects_low_parse_confidence(self): assert compute_patient_drift_score(self._pd_row(parse_confidence_overall=0.40)) == 0.0 def test_rejects_high_oneoff_penalty(self): assert compute_patient_drift_score(self._pd_row(oneoff_penalty=0.45)) == 0.0 def test_higher_reaction_scores_higher(self): low = compute_patient_drift_score(self._pd_row(reaction_day_return=0.02)) high = compute_patient_drift_score(self._pd_row(reaction_day_return=0.10)) assert high > low def test_higher_close_location_scores_higher(self): low = compute_patient_drift_score(self._pd_row(close_location=0.45)) high = compute_patient_drift_score(self._pd_row(close_location=0.85)) assert high > low def test_nlp_features_dont_affect_score(self): base = compute_patient_drift_score(self._pd_row()) with_nlp = compute_patient_drift_score(self._pd_row( document_quality_score=0.90, guidance_direction_score=1.0, signal_strength_score=0.95, )) assert with_nlp == pytest.approx(base) class TestMicrostructureScore: """Microstructure scoring — event-agnostic, pure price signals.""" def _ms_row(self, **overrides): base = { "reaction_day_return": 0.03, "close_location": 0.80, "volume_ratio_20d": 0.7, "gap_size": 0.005, } base.update(overrides) return base def test_quiet_conviction_setup_scores_well(self): score = compute_microstructure_score(self._ms_row()) assert score > 0.45 def test_score_bounded_0_to_1(self): extremes = [ self._ms_row(reaction_day_return=0.001, close_location=0.1), self._ms_row(reaction_day_return=0.20, close_location=1.0), ] for row in extremes: score = compute_microstructure_score(row) assert 0.0 <= score <= 1.0 def test_accepts_any_event_type(self): for event_type in ["earnings_release", "guidance_update", "management_change", "material_contract", "other_material_event"]: score = compute_microstructure_score(self._ms_row(event_type=event_type)) assert score > 0.0 def test_rejects_negative_reaction(self): assert compute_microstructure_score(self._ms_row(reaction_day_return=-0.02)) == 0.0 assert compute_microstructure_score(self._ms_row(reaction_day_return=0.0)) == 0.0 def test_lower_volume_scores_higher(self): """Below-average volume = quiet conviction = higher score.""" quiet = compute_microstructure_score(self._ms_row(volume_ratio_20d=0.6)) loud = compute_microstructure_score(self._ms_row(volume_ratio_20d=1.8)) assert quiet > loud def test_higher_close_location_scores_higher(self): low = compute_microstructure_score(self._ms_row(close_location=0.55)) high = compute_microstructure_score(self._ms_row(close_location=0.90)) assert high > low def test_nlp_features_dont_affect_score(self): base = compute_microstructure_score(self._ms_row()) with_nlp = compute_microstructure_score({ **self._ms_row(), "document_quality_score": 0.90, "guidance_direction_score": 1.0, "event_direction": "bullish", }) assert with_nlp == pytest.approx(base) def test_no_event_type_still_works(self): score = compute_microstructure_score(self._ms_row()) assert score > 0.0 class TestEarningsSurpriseScore: """Earnings surprise (SUE) scoring.""" def test_strong_beat(self): assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.30}) == 0.9 def test_moderate_beat(self): assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.10}) == 0.75 def test_inline(self): assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.0}) == 0.5 def test_moderate_miss(self): assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": -0.10}) == 0.25 def test_severe_miss(self): assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": -0.30}) == 0.1 def test_non_earnings_returns_neutral(self): assert _earnings_surprise_score({"event_type": "guidance_update", "eps_growth_qoq": 0.30}) == 0.5 def test_missing_returns_neutral(self): assert _earnings_surprise_score({"event_type": "earnings_release"}) == 0.5 def test_no_event_type_returns_neutral(self): assert _earnings_surprise_score({}) == 0.5 class TestEventQualityScore: """Event quality scoring.""" def test_strong_signals(self): score = _event_quality_score({ "signal_strength_score": 0.8, "guidance_direction_score": 1.0, "document_quality_score": 0.7, }) assert score > 0.7 def test_weak_signals(self): score = _event_quality_score({ "signal_strength_score": 0.0, "guidance_direction_score": 0.0, "document_quality_score": 0.3, }) assert score < 0.15 def test_missing_returns_neutral(self): assert _event_quality_score({}) == 0.5 def test_partial_features(self): """Works with only some event features present.""" score = _event_quality_score({"signal_strength_score": 0.8}) assert score == pytest.approx(0.8, abs=0.01) class TestRiskPenaltyScore: """Risk penalty scoring.""" def test_no_risk(self): assert _risk_penalty_score({"oneoff_penalty": 0.0}) == pytest.approx(0.9) def test_max_risk(self): assert _risk_penalty_score({"oneoff_penalty": 1.0}) == pytest.approx(0.2) def test_moderate_risk(self): score = _risk_penalty_score({"oneoff_penalty": 0.5}) assert 0.4 < score < 0.7 def test_missing_returns_neutral(self): assert _risk_penalty_score({}) == 0.5 class TestParseConfidenceScore: """Parse confidence scoring.""" def test_high_confidence(self): assert _parse_confidence_score({"parse_confidence_overall": 0.9}) == 0.9 def test_moderate_confidence(self): assert _parse_confidence_score({"parse_confidence_overall": 0.7}) == 0.7 def test_borderline_confidence(self): assert _parse_confidence_score({"parse_confidence_overall": 0.55}) == 0.5 def test_low_confidence(self): assert _parse_confidence_score({"parse_confidence_overall": 0.45}) == 0.3 def test_very_low_confidence(self): assert _parse_confidence_score({"parse_confidence_overall": 0.3}) == 0.2 def test_missing_returns_neutral(self): assert _parse_confidence_score({}) == 0.5 class TestDirectionClarityScore: """Direction clarity scoring.""" def test_bullish(self): assert _direction_clarity_score({"event_direction": "bullish"}) == 0.9 def test_mixed(self): assert _direction_clarity_score({"event_direction": "mixed"}) == 0.4 def test_neutral(self): assert _direction_clarity_score({"event_direction": "neutral"}) == 0.3 def test_bearish(self): assert _direction_clarity_score({"event_direction": "bearish"}) == 0.1 def test_unknown(self): assert _direction_clarity_score({"event_direction": "unknown"}) == 0.2 def test_missing_returns_neutral(self): assert _direction_clarity_score({}) == 0.5 class TestReturnMaxLongScoreV11: """Positive-only auxiliary bonuses should gently perturb V5, not flip it.""" @staticmethod def _base_row() -> dict[str, float | str]: return { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.012, "close_location": 0.72, "volume_ratio_20d": 1.45, "gap_size": 0.008, "oneoff_penalty": 0.05, "avg_dollar_volume_20d": 250000000.0, "market_cap": 25000000000.0, "parse_confidence_overall": 0.85, "signal_strength_score": 0.8, "guidance_direction_score": 0.9, "document_quality_score": 0.75, } def test_v11_adds_small_bonus_when_aux_signals_are_favorable(self): row = self._base_row() | { "prior_event_fwd5d": 0.08, "macro_vix": 22.0, "macro_hy_spread": 3.8, } baseline = compute_return_max_long_score_v5(row) v11 = compute_return_max_long_score_v11(row) v11g = compute_return_max_long_score_v11g(row) assert v11 > baseline assert v11g > baseline assert v11 > v11g def test_v11_does_not_penalize_when_aux_signals_are_adverse(self): row = self._base_row() | { "prior_event_fwd5d": -0.08, "macro_vix": 13.0, "macro_hy_spread": 2.9, } baseline = compute_return_max_long_score_v5(row) v11 = compute_return_max_long_score_v11(row) v11g = compute_return_max_long_score_v11g(row) assert v11 == pytest.approx(baseline, abs=1e-9) assert v11g == pytest.approx(baseline, abs=1e-9) class TestReturnMaxLongScoreV18: """Interaction-heavy v18 family should reward aligned tier3+tech states.""" @staticmethod def _base_row() -> dict[str, float | str]: return { "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "reaction_day_return": 0.055, "close_location": 0.78, "volume_ratio_20d": 1.8, "gap_size": 0.012, "oneoff_penalty": 0.08, "parse_confidence_overall": 0.82, "signal_strength_score": 0.82, "guidance_direction_score": 0.88, "document_quality_score": 0.76, "pre_event_entropy_60d": 1.72, "pre_event_hurst_60d": 0.59, "pre_event_sector_momentum_20d": 0.03, "pre_event_ou_theta_60d": 0.03, "pre_event_gravitational_pull": 0.60, "pre_event_market_temperature": 0.82, "pre_event_obv_slope_20d": -0.02, "pre_event_bb_position": 0.42, "pre_event_rsi_14": 42.0, } def test_v18_family_prefers_aligned_low_chaos_setup(self): good = self._base_row() bad = self._base_row() | { "pre_event_entropy_60d": 2.35, "pre_event_hurst_60d": 0.43, "pre_event_sector_momentum_20d": -0.03, "pre_event_ou_theta_60d": 0.19, "pre_event_gravitational_pull": 2.4, "pre_event_market_temperature": 1.55, "pre_event_obv_slope_20d": 0.03, "pre_event_bb_position": 1.08, "pre_event_rsi_14": 74.0, } assert compute_return_max_long_score_v18(good) > compute_return_max_long_score_v18(bad) assert compute_return_max_long_score_v18b(good) > compute_return_max_long_score_v18b(bad) assert compute_return_max_long_score_v18c(good) > compute_return_max_long_score_v18c(bad) assert compute_return_max_long_score_v18d(good) > compute_return_max_long_score_v18d(bad) def test_v18d_escape_velocity_penalizes_weak_reaction_in_noisy_field(self): strong = self._base_row() weak = self._base_row() | { "reaction_day_return": 0.018, "pre_event_entropy_60d": 2.25, "pre_event_market_temperature": 1.45, "pre_event_gravitational_pull": 2.2, } assert compute_return_max_long_score_v18d(strong) > compute_return_max_long_score_v18d(weak)