"""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)
