"""Rule-based entry score model for the backtester.

Computes a composite score in [0, 1] from alpha-only features available at
entry time (no forward-looking data).  Higher score = more favorable entry
conditions for a long swing trade.

Weights are empirically calibrated from signal quality analysis on 1,675+
events (2022-07 to 2026-03).  Components that showed no signal or
anti-signal were removed.

Active components (3, 100% weight):
  Event Quality (65%):
    - Event quality — parser confidence + signal strength + guidance
    - Only component with monotonic win-rate increase across buckets

  Market Confirmation (35%):
    - Reaction direction (20%) — simple directional: positive > flat > negative
    - Volume conviction (15%) — above-average volume confirms conviction

Removed components (empirically no signal or anti-signal):
    - Close strength — data showed inverted relationship (close near high = worse)
    - Gap quality — no sorting power across 1,675 events
    - Earnings surprise — eps_growth_qoq is sequential growth, not real surprise
    - Risk penalty — already hard-gated at Gate 10
    - Parse confidence — already hard-gated at Gate 11
    - Direction clarity — already hard-gated at Gates 12-13
    - LM sentiment — 70-word dictionary is noise for SEC filings
"""
from __future__ import annotations

from typing import Any

from libs.common.logging import get_logger

logger = get_logger(__name__)


_RETURN_MAX_LONG_V2_WEIGHTS = {
    "document_quality": 0.20,
    "guidance_direction": 0.12,
    "oneoff_inverse": 0.08,
    "parse_overall": 0.05,
    "parse_direction": 0.05,
    "reaction": 0.15,
    "close_location": 0.10,
    "volume": 0.08,
    "gap": 0.07,
}

_RETURN_MAX_LONG_V3_WEIGHTS = {
    "document_quality": 0.14,
    "guidance_direction": 0.08,
    "oneoff_inverse": 0.07,
    "parse_overall": 0.03,
    "parse_direction": 0.03,
    "reaction": 0.24,
    "close_location": 0.16,
    "volume": 0.10,
    "gap": 0.05,
}

_RETURN_MAX_LONG_ML_V2_FEATURES = (
    "reaction_day_return",
    "gap_size",
    "close_location",
    "volume_ratio_20d",
    "document_quality_score",
    "parse_confidence_overall",
    "oneoff_penalty",
    "market_cap_proxy",
    "avg_dollar_volume_20d",
    "pre_event_entropy_60d",
    "pre_event_gravitational_pull",
    "pre_event_hurst_60d",
    "pre_event_market_temperature",
    "pre_event_ou_theta_60d",
    "pre_event_volatility_20d",
    "pre_event_rsi_14",
    "pre_event_bb_position",
    "pre_event_obv_slope_20d",
    "macro_vix",
    "macro_hy_spread",
    "macro_t10y2y",
    "prior_event_fwd5d",
    "lm_positive_pct",
    "lm_negative_pct",
    "lm_net_sentiment",
    "earnings_surprise_pct",
)
_RETURN_MAX_LONG_ML_V2_MEDIANS = (
    0.00043796268831433567,
    0.0014147341847357661,
    0.491144432431328,
    1.7471348973965604,
    0.5652,
    0.538,
    0.3333333333333333,
    33840252928.0,
    211056559.90320206,
    1.8656900250589923,
    1.646267696185956,
    0.5997768089966802,
    0.9473339634670879,
    0.07390133614831655,
    0.01756562718850168,
    53.54057861323522,
    0.5891477752380258,
    0.05511243484782039,
    15.84,
    3.2,
    -0.21,
    0.005737396762029504,
    0.002101,
    0.001027,
    0.0004285,
    5.41,
)
_RETURN_MAX_LONG_ML_V2_MEANS = (
    0.0032582434636562216,
    0.0035941803987159814,
    0.49789102050187767,
    2.2204886406770665,
    0.5765680487804878,
    0.5558378048780488,
    0.4033536585365854,
    121359939981.15121,
    569833415.8599782,
    1.8111128627932631,
    1.9178768731718259,
    0.5993916206513497,
    0.9772700012692805,
    0.0930214002992398,
    0.021797492929273746,
    53.27715050355677,
    0.5612362654662348,
    0.05220690800032629,
    16.741275914634148,
    3.3686143292682926,
    -0.181782012195122,
    0.0070169607624383165,
    0.004126396341463415,
    0.002112548475609756,
    0.002011094512195122,
    9.073597530487804,
)
_RETURN_MAX_LONG_ML_V2_SCALES = (
    0.0720229227164041,
    0.059017403591568034,
    0.30109097079611835,
    1.7791554894359867,
    0.11551029730719951,
    0.12749633867554488,
    0.36849681780978466,
    376537958659.33057,
    2019713069.1204512,
    0.25865153729782864,
    1.4260850821637705,
    0.1349994583356394,
    0.38424055351326725,
    0.07999123225095692,
    0.016667513800963603,
    17.822845453925048,
    0.4065215223535034,
    0.24174830009237133,
    3.656293322040498,
    0.6370337970431081,
    0.3353366683496654,
    0.04836133779385634,
    0.004925452240154967,
    0.002937533648395367,
    0.005087427053986574,
    162.48548260791046,
)
_RETURN_MAX_LONG_ML_V2_COEFFICIENTS = (
    0.12267321524336684,
    -0.09758984044985207,
    -0.14089590256871018,
    0.20920411932983002,
    0.36849774883885045,
    -0.3065371678751244,
    -0.0097733831921906,
    -0.12338809439110936,
    0.2721540850531583,
    0.2748841199015877,
    -0.19359210589527456,
    0.020699991539144882,
    -0.1372657179049571,
    -0.09676797576582977,
    2.2289710150127107,
    0.10555479229614609,
    -0.10817108771075115,
    -0.10294022818336451,
    0.053637765359040426,
    0.0316402350515546,
    0.28702444965666424,
    -0.01565075524901778,
    0.08130366466684952,
    -0.03591552791019765,
    -0.0834092973465427,
    0.9519857171253503,
)
_RETURN_MAX_LONG_ML_V2_INTERCEPT = 0.34221813652373806

_RETURN_MAX_LONG_ML_V3_FEATURES = (
    "reaction_day_return",
    "gap_size",
    "close_location",
    "volume_ratio_20d",
    "document_quality_score",
    "parse_confidence_overall",
    "oneoff_penalty",
    "market_cap_proxy",
    "avg_dollar_volume_20d",
    "pre_event_entropy_60d",
    "pre_event_gravitational_pull",
    "pre_event_hurst_60d",
    "pre_event_market_temperature",
    "pre_event_ou_theta_60d",
    "pre_event_volatility_20d",
    "pre_event_rsi_14",
    "pre_event_bb_position",
    "pre_event_obv_slope_20d",
    "macro_vix",
    "macro_hy_spread",
    "macro_t10y2y",
    "prior_event_fwd5d",
    "lm_positive_pct",
    "lm_negative_pct",
    "lm_net_sentiment",
    "earnings_surprise_pct",
    "sue_lag_1_pct",
    "sue_lag_2_pct",
    "sue_lag_3_pct",
    "sue_hist_mean_4q",
    "sue_hist_mean_8q",
    "sue_hist_mean_12q",
    "sue_hist_pos_rate_4q",
    "sue_hist_pos_rate_12q",
    "sue_hist_latest_pct",
    "sue_hist_streak_pos",
    "peer_sector_event_count_365d",
    "peer_sector_surprise_median_365d",
    "peer_sector_surprise_mean_365d",
    "peer_sector_surprise_pos_rate_365d",
    "peer_relative_surprise_pct_365d",
    "peer_sector_sue_hist_mean_4q_median_365d",
    "peer_sector_sue_hist_mean_4q_mean_365d",
    "peer_relative_sue_hist_mean_4q_365d",
    "peer_sector_sue_hist_pos_rate_4q_mean_365d",
)
_RETURN_MAX_LONG_ML_V3_MEDIANS = (
    0.00043796268831433567,
    0.0014147341847357661,
    0.491144432431328,
    1.7471348973965604,
    0.5652,
    0.538,
    0.3333333333333333,
    33840252928.0,
    211056559.90320206,
    1.8656900250589923,
    1.646267696185956,
    0.5997768089966802,
    0.9473339634670879,
    0.07390133614831655,
    0.01756562718850168,
    53.54057861323522,
    0.5891477752380258,
    0.05511243484782039,
    15.84,
    3.2,
    -0.21,
    0.005737396762029504,
    0.002101,
    0.001027,
    0.0004285,
    5.41,
    5.74,
    6.35,
    6.78,
    6.5225,
    6.5225,
    6.5225,
    1.0,
    1.0,
    5.71715,
    1.0,
    5.0,
    8.11,
    9.238888888888889,
    0.8305084745762712,
    -0.5930750000000002,
    6.46,
    14.167990021929825,
    0.22333333333333272,
    0.7869047619047619,
)
_RETURN_MAX_LONG_ML_V3_MEANS = (
    0.0032582434636562216,
    0.0035941803987159814,
    0.49789102050187767,
    2.2204886406770665,
    0.5765680487804878,
    0.5558378048780488,
    0.4033536585365854,
    121359939981.15121,
    569833415.8599782,
    1.8111128627932631,
    1.9178768731718259,
    0.5993916206513497,
    0.9772700012692805,
    0.0930214002992398,
    0.021797492929273746,
    53.27715050355677,
    0.5612362654662348,
    0.05220690800032629,
    16.741275914634148,
    3.3686143292682926,
    -0.181782012195122,
    0.0070169607624383165,
    0.004126396341463415,
    0.002112548475609756,
    0.002011094512195122,
    9.073597530487804,
    3.5187182012195115,
    6.851466509146342,
    6.883917957317073,
    5.594755612296748,
    5.594755612296748,
    5.594755612296748,
    0.9639481707317074,
    0.9639481707317074,
    3.498086951219514,
    1.0644817073170731,
    12.055335365853658,
    7.959569230182926,
    11.22887578249851,
    0.8254561829513042,
    2.338585868902439,
    6.587968925304877,
    13.995408413628205,
    -0.7883605995934964,
    0.7870381616238329,
)
_RETURN_MAX_LONG_ML_V3_SCALES = (
    0.0720229227164041,
    0.059017403591568034,
    0.30109097079611835,
    1.7791554894359867,
    0.11551029730719951,
    0.12749633867554488,
    0.36849681780978466,
    376537958659.33057,
    2019713069.1204512,
    0.25865153729782864,
    1.4260850821637705,
    0.1349994583356394,
    0.38424055351326725,
    0.07999123225095692,
    0.016667513800963603,
    17.822845453925048,
    0.4065215223535034,
    0.24174830009237133,
    3.656293322040498,
    0.6370337970431081,
    0.3353366683496654,
    0.04836133779385634,
    0.004925452240154967,
    0.002937533648395367,
    0.005087427053986574,
    162.48548260791046,
    222.61038856413487,
    74.59061476608642,
    50.26701526769874,
    134.7529157601218,
    134.7529157601218,
    134.7529157601218,
    0.16129628605046287,
    0.16129628605046287,
    222.61020493014973,
    0.438213571003591,
    21.530398650421372,
    2.1857798642883264,
    12.6082138253217,
    0.06541432061390462,
    161.4887497381566,
    3.187426548400984,
    16.560638169447746,
    134.75182576886135,
    0.050455645804338346,
)
_RETURN_MAX_LONG_ML_V3_COEFFICIENTS = (
    0.14007421317292182,
    -0.12003815541366457,
    -0.14717276967184498,
    0.18313676269153908,
    0.14179813559884488,
    -0.09000853444472605,
    -0.046581801850541525,
    -0.12284271029788699,
    0.26525002968260414,
    0.2710189723794223,
    -0.19222136981416527,
    0.02464053107964807,
    -0.13718507142293965,
    -0.09866125296485219,
    2.2135404380489256,
    0.10638301315964269,
    -0.10767923785991367,
    -0.1089426720380704,
    0.03699092632363811,
    0.07117187147333857,
    0.27285960273776827,
    -0.01103334295655712,
    0.03060055361239007,
    0.0038679021609147627,
    -0.03316393853491961,
    1.0013440947591314,
    -0.052553942952412135,
    0.0208667186502828,
    -0.009008618193599565,
    -0.007862018406960523,
    -0.007862018406960523,
    -0.007862018406960523,
    -0.000176632177643848,
    -0.000176632177643848,
    -0.04509124507569923,
    -0.031925265957652584,
    0.0912446875408573,
    -0.052360698463861294,
    0.11784159306631489,
    -0.007059253375004289,
    -0.10963120188333718,
    -0.02166764961835262,
    0.03822657454068858,
    0.15606421258538145,
    0.07028148651563548,
)
_RETURN_MAX_LONG_ML_V3_INTERCEPT = 0.3435877520237345


def _compute_numeric_logistic_score(
    row: dict[str, Any],
    *,
    features: tuple[str, ...],
    medians: tuple[float, ...],
    means: tuple[float, ...],
    scales: tuple[float, ...],
    coefficients: tuple[float, ...],
    intercept: float,
) -> float:
    import math

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None:
        return 0.0

    event_type = str(row.get("event_type", "")).lower()
    if event_type not in {
        "earnings_release",
        "guidance_update",
        "material_contract",
        "other_material_event",
        "management_change",
        "unknown",
    }:
        return 0.0

    z = intercept
    for idx, feature in enumerate(features):
        value = _safe_float(row.get(feature))
        if value is None:
            value = medians[idx]
        scaled = (value - means[idx]) / max(scales[idx], 1e-12)
        z += scaled * coefficients[idx]

    z = max(min(z, 60.0), -60.0)
    return _clamp(1.0 / (1.0 + math.exp(-z)))


def compute_entry_score(row: dict[str, Any]) -> float:
    """Compute composite entry score from empirically validated features.

    Components and weights (calibrated on 1,675+ events):
      1. Event quality       (65%) — only monotonic signal in bucket analysis
      2. Reaction direction  (20%) — simple positive/negative directional
      3. Volume conviction   (15%) — above-average confirms conviction

    Returns float in [0.0, 1.0].
    """
    event = _event_quality_score(row)
    reaction = _reaction_score(row)
    volume = _volume_score(row)

    raw = (
        event * 0.65
        + reaction * 0.20
        + volume * 0.15
    )
    return max(0.0, min(1.0, raw))


def compute_pead_score(
    row: dict[str, Any],
    reaction_threshold: float = 0.05,
    volume_threshold: float = 1.5,
) -> float:
    """Continuous PEAD score based on reaction strength, volume, and gap alignment.

    Academically grounded: stocks with strong earnings reactions (PEAD)
    continue to drift in the same direction. Supports both long (positive
    reaction) and short (negative reaction) sides.

    For short-side, sets row["trade_direction"] = "short" as a side effect
    so downstream components know to use short execution logic.

    Score components (weighted):
      - Reaction magnitude (60%): continuous 0.0-1.0 based on abs(return)
      - Volume conviction  (25%): continuous 0.0-1.0 based on volume_ratio
      - Gap alignment      (15%): bonus when gap direction matches reaction

    Returns continuous score in [0.0, 1.0]. Scores above ~0.5 indicate
    qualifying PEAD setups. Returns 0.0 if below threshold or missing data.
    """
    ret = row.get("reaction_day_return")
    vol = row.get("volume_ratio_20d")

    if ret is None or vol is None:
        return 0.0

    abs_ret = abs(ret)

    # Gate: must pass minimum reaction and volume thresholds
    if abs_ret < reaction_threshold or vol < volume_threshold:
        return 0.0

    # Set trade direction
    if ret >= reaction_threshold:
        row["trade_direction"] = "long"
    else:
        row["trade_direction"] = "short"

    # --- Component 1: Reaction magnitude (60%) ---
    # Linear scaling: threshold -> 0.60, 15%+ -> 0.95, capped at 1.0
    reaction_score = min(1.0, 0.60 + (abs_ret - reaction_threshold) * (0.35 / 0.08))

    # --- Component 2: Volume conviction (25%) ---
    # Linear scaling: volume_ratio / 5.0, capped at 1.0
    volume_score = min(1.0, vol / 5.0)

    # --- Component 3: Gap alignment (15%) ---
    gap = row.get("gap_size")
    if gap is not None:
        gap = float(gap)
        if ret > 0:
            # Long: positive gap aligns with positive reaction
            gap_score = min(1.0, max(0.0, gap / 0.03)) if gap > 0 else 0.2
        else:
            # Short: negative gap aligns with negative reaction
            gap_score = min(1.0, max(0.0, abs(gap) / 0.03)) if gap < 0 else 0.2
    else:
        gap_score = 0.5  # neutral when missing

    # Weighted composite
    raw = reaction_score * 0.60 + volume_score * 0.25 + gap_score * 0.15
    return max(0.0, min(1.0, raw))


def compute_return_max_long_score(row: dict[str, Any]) -> float:
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=False,
        use_signal_strength_proxy=False,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
    )


def compute_return_max_long_score_v2(row: dict[str, Any]) -> float:
    """V2 long-biased score with earnings-direction fallback.

    For earnings releases, parser event_direction may be mixed/unknown even when
    the market reaction is unambiguously positive. V2 allows those cases when
    the reaction itself is positive, while still hard-rejecting explicitly
    bearish parser outputs.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
    )


def compute_return_max_long_score_v3(row: dict[str, Any]) -> float:
    """V3 long-biased score with a more market-confirmed ranking profile.

    V3 keeps the same hard gates and earnings fallback as v2, but tilts the
    ranking toward reaction/close/volume and away from parser-quality terms.
    The goal is to preserve v2's eligibility semantics while changing only the
    ordering inside the already-eligible cohort.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V3_WEIGHTS,
    )


def compute_return_max_long_score_v4(row: dict[str, Any]) -> float:
    """Backward-compatible alias for v5."""
    return compute_return_max_long_score_v5(row)


def compute_return_max_long_score_v5(row: dict[str, Any]) -> float:
    """V5 keeps v2 semantics and adds a conservative generic material-event branch."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
    )


def compute_return_max_long_score_v6(row: dict[str, Any]) -> float:
    """V6 replaces linear market scoring with empirical zone functions.

    Zone functions capture non-linear optimal regions discovered in data:
      - Reaction: peak at 8-20%, drops at 25%+ (mean reversion)
      - Close location: peak at 80-90%, drops at 92%+ (exhaustion)
      - Volume: peak at 3-5x, drops at 6x+ (overhyped)
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_zone_scoring=True,
    )


def compute_return_max_long_score_v7(row: dict[str, Any]) -> float:
    """V7 = V5 + market-confirmed parse gate relaxation.

    For earnings with strong market confirmation (ret>5%, close>0.55, vol>1.3),
    the parse_confidence gate is lowered from 0.60 to 0.45. This unlocks
    events where the parser was uncertain but the market clearly validated.
    Only affects events currently at score=0 due to parse<0.60.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_market_confirmed_gate=True,
    )


def compute_return_max_long_score_v8(row: dict[str, Any]) -> float:
    """V8 = V5 + conditional financial feature bonus.

    For earnings_release events with eps_growth_qoq/revenue_growth_qoq data,
    adds a conservative bonus (up to 7%) to the score. Only applied when
    financial data is present (not null). Designed for snapshots that include
    financial_v1 features.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_financial_bonus=True,
    )


def _compute_generic_material_event_score(row: dict[str, Any]) -> float:
    """Score orderly after-close material events without using earnings-specific logic."""
    parse_conf_overall = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf_overall is None or parse_conf_overall < 0.45:
        return 0.0

    oneoff_penalty = _safe_float(row.get("oneoff_penalty"))
    if oneoff_penalty is None or oneoff_penalty >= 0.40:
        return 0.0

    reaction_day_return = _safe_float(row.get("reaction_day_return"))
    if reaction_day_return is None or reaction_day_return <= -0.05:
        return 0.0

    document_quality = max(
        _normalized(row.get("document_quality_score")),
        _normalized(row.get("signal_strength_score")),
    )
    document_component = (
        document_quality * 0.18
        + _normalized(parse_conf_overall) * 0.12
        + (1.0 - _normalized(oneoff_penalty)) * 0.10
    )
    market_component = (
        _orderly_material_close_score(row.get("close_location")) * 0.22
        + _normalize_linear(reaction_day_return, -0.02, 0.08) * 0.18
        + _orderly_material_volume_score(row.get("volume_ratio_20d")) * 0.10
        + _orderly_material_gap_score(row) * 0.10
    )
    return _clamp(document_component + market_component - _overheat_penalty(row))


def _orderly_material_close_score(raw: Any) -> float:
    value = _safe_float(raw)
    if value is None:
        return 0.0
    if value <= 0.45:
        return 0.0
    if value <= 0.70:
        return _normalize_linear(value, 0.45, 0.70)
    if value <= 0.85:
        return 1.0 - 0.30 * _normalize_linear(value, 0.70, 0.85)
    if value >= 0.95:
        return 0.20
    return 0.70 - 0.50 * _normalize_linear(value, 0.85, 0.95)


def _orderly_material_volume_score(raw: Any) -> float:
    value = _safe_float(raw)
    if value is None:
        return 0.5
    if value <= 0.80:
        return 0.20
    if value <= 1.20:
        return 0.20 + 0.80 * _normalize_linear(value, 0.80, 1.20)
    if value <= 2.00:
        return 1.0 - 0.40 * _normalize_linear(value, 1.20, 2.00)
    if value <= 3.00:
        return 0.60 - 0.40 * _normalize_linear(value, 2.00, 3.00)
    return 0.20


def _orderly_material_gap_score(row: dict[str, Any]) -> float:
    gap = _safe_float(row.get("gap_size"))
    if gap is None:
        return 0.5
    if -0.02 <= gap <= 0.02:
        return 1.0
    if -0.04 <= gap < -0.02:
        return 0.45
    if 0.02 < gap <= 0.05:
        return 0.50
    return 0.0


def compute_return_max_long_score_v10(row: dict[str, Any]) -> float:
    """V10 = V5 + macro regime bonus from VIX and HY spread.

    High VIX (>18) + wide HY spread (>3.25) = favorable PEAD regime.
    Empirical: 62.3% WR vs 50.8% in neutral regime (+11.5pp, n=2648).
    Adds up to +12% bonus in favorable regime, -5% penalty in adverse regime.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        macro_regime_weight=0.12,
    )


def compute_return_max_long_score_v9g(row: dict[str, Any]) -> float:
    """V9G = V9 + hard gate: reject events with negative prior same-ticker drift.

    When prior_event_fwd5d < 0 (and is available), returns 0.0.
    Null prior (first event for ticker) passes through.
    """
    prior = _safe_float(row.get("prior_event_fwd5d"))
    if prior is not None and prior < 0.0:
        return 0.0
    return compute_return_max_long_score_v9(row)


def compute_return_max_long_score_v9(row: dict[str, Any]) -> float:
    """V9 = V5 + cross-event momentum bonus from prior same-ticker drift.

    When the same ticker's prior event had positive 5d drift (> +2%),
    adds a bonus (up to 10%) reflecting PEAD persistence. When prior
    drift was negative (< -2%), applies a penalty. Neutral when no
    prior event or prior drift near zero.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        prior_drift_weight=0.10,
    )


def compute_return_max_long_score_v11(row: dict[str, Any]) -> float:
    """V11 = V5 + positive-only micro bonuses from macro regime and prior drift.

    This keeps V5 eligibility intact and only nudges ordering for already-strong
    candidates. Unlike v9/v10, adverse macro or negative prior drift do not
    penalize the score.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        prior_drift_weight=0.02,
        macro_regime_weight=0.03,
        positive_only_aux_bonus=True,
    )


def compute_return_max_long_score_v11g(row: dict[str, Any]) -> float:
    """V11G = gentler V11, intended as a near-tiebreak perturbation."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        prior_drift_weight=0.01,
        macro_regime_weight=0.02,
        positive_only_aux_bonus=True,
    )


def compute_return_max_long_score_v15(row: dict[str, Any]) -> float:
    """V15 = V5 + CONTRARIAN bonuses: low OBV + low BB + low OU = PEAD friendly.

    Data shows: pre-event distribution (OBV Q1) has 56.4% WR vs accumulation (Q5) 51.2%.
    PEAD works best when stocks were sold off before the event — contrarian reversal.
    """
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv=True,
    )


def compute_return_max_long_score_v15b(row: dict[str, Any]) -> float:
    """V15B = V5 + OBV + BB contrarian combined."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv=True, use_contrarian_bb=True,
    )


def compute_return_max_long_score_v15c(row: dict[str, Any]) -> float:
    """V15C = V5 + OBV + BB + OU contrarian all three."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv=True, use_contrarian_bb=True, use_ou_bonus=True,
    )


def compute_return_max_long_score_v15d(row: dict[str, Any]) -> float:
    """V15D = V5 + strong OBV contrarian boost (+0.08) for trade count increase."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv_strong=True,
    )


def compute_return_max_long_score_v15e(row: dict[str, Any]) -> float:
    """V15E = V5 + contrarian OBV/BB + entropy bonus."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv=True, use_contrarian_bb=True, use_entropy_bonus=True,
    )


def compute_return_max_long_score_v15f(row: dict[str, Any]) -> float:
    """V15F = V5 + ALL contrarian signals reversed (RSI low=bonus, BB low=bonus, OBV low=bonus)."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_contrarian_obv=True, use_contrarian_bb=True, use_contrarian_rsi=True,
        use_ou_bonus=True, use_entropy_bonus=True,
    )


def compute_return_max_long_score_v14(row: dict[str, Any]) -> float:
    """V14 = V5 + Tier 3: OU theta + gravitational pull + market temperature."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_tier3_bonuses=True,
    )


def compute_return_max_long_score_v14_ou(row: dict[str, Any]) -> float:
    """V14_OU = V5 + OU theta bonus only."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_ou_bonus=True,
    )


def compute_return_max_long_score_v14_gp(row: dict[str, Any]) -> float:
    """V14_GP = V5 + gravitational pull bonus only."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_grav_bonus=True,
    )


def compute_return_max_long_score_v14_mt(row: dict[str, Any]) -> float:
    """V14_MT = V5 + market temperature bonus only."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_temp_bonus=True,
    )


def compute_return_max_long_score_v14e(row: dict[str, Any]) -> float:
    """V14E = V5 + Tier 3 + Entropy (best of Tier 2)."""
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_tier3_bonuses=True, use_entropy_bonus=True,
    )


def compute_return_max_long_score_v13(row: dict[str, Any]) -> float:
    """V13 = V5 + Tier 2 bonuses: Hurst trending + low entropy + sector tailwind."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
    )


def compute_return_max_long_score_v13h(row: dict[str, Any]) -> float:
    """V13H = V5 + Hurst trending bonus only."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_hurst_bonus=True,
    )


def compute_return_max_long_score_v13e(row: dict[str, Any]) -> float:
    """V13E = V5 + low entropy bonus only."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
    )


def compute_return_max_long_score_v13e_surp(row: dict[str, Any]) -> float:
    """V13E_SURP = V13E + earnings surprise bonus.

    Adds actual vs estimated EPS surprise signal on top of V13E.
    Sweet spot: 0-3% beat → strongest PEAD (gradual repricing).
    Note: ~22% coverage for earnings_release events; null returns 0.0.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_earnings_surprise_bonus=True,
    )


def compute_return_max_long_score_v13e_pd(row: dict[str, Any]) -> float:
    """V13E_PD = V13E + prior drift positive-only bonus.

    Cross-event momentum: prior same-ticker 5d drift > +2% → +5% bonus.
    Positive-only (no penalty). 76% coverage (null → 0).
    Empirical: 8.1pp WR spread between positive vs negative prior drift.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        prior_drift_weight=0.05,
        positive_only_aux_bonus=True,
    )


def compute_return_max_long_score_v13e_mr(row: dict[str, Any]) -> float:
    """V13E_MR = V13E + macro regime positive-only bonus.

    VIX > 18 AND HY spread > 3.25 → fear regime → +5% bonus.
    Positive-only (no penalty). 100% coverage.
    Empirical: 62.3% WR in fear regime vs 50.8% otherwise (11.5pp spread).
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        macro_regime_weight=0.05,
        positive_only_aux_bonus=True,
    )


def compute_return_max_long_score_v13e_pdmr(row: dict[str, Any]) -> float:
    """V13E_PDMR = V13E + prior drift + macro regime (both positive-only).

    Combined: prior drift bonus + macro fear regime bonus. V11 style on top of V13E.
    Both scaled 0.05, positive_only (no penalties from either signal).
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        prior_drift_weight=0.05,
        macro_regime_weight=0.05,
        positive_only_aux_bonus=True,
    )


def compute_return_max_long_score_v13e_pd2(row: dict[str, Any]) -> float:
    """V13E_PD2 = V13E + prior drift bidirectional (bonus + penalty).

    Bidirectional: positive prior drift → +10% bonus, negative → -10% penalty.
    More aggressive use of 8.1pp WR spread vs positive-only version.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        prior_drift_weight=0.10,
    )


def compute_return_max_long_score_v13e_h(row: dict[str, Any]) -> float:
    """V13E_H = V13E + Hurst trending bonus.

    Hurst > 0.55 (trending) → +3% bonus: PEAD drift more likely to persist.
    Hurst < 0.45 (mean-reverting) → -2% penalty: drift tends to fade quickly.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_hurst_bonus=True,
    )


def compute_return_max_long_score_v13e_ou(row: dict[str, Any]) -> float:
    """V13E_OU = V13E + OU theta bonus.

    OU theta < 0.02 (slow mean-reversion) → +3%: price stays at new level longer.
    OU theta > 0.15 (fast mean-reversion) → -2%: drift dissipates quickly.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_ou_bonus=True,
    )


def compute_return_max_long_score_v13e_t3(row: dict[str, Any]) -> float:
    """V13E_T3 = V13E + tier3 bonuses (OU + gravitational pull + market temperature).

    Three tier3 signals combined: slow mean-reversion (OU), proximity to MAs (grav),
    and market temperature regime (temp). Each ±2–3%.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_tier3_bonuses=True,
    )


def compute_return_max_long_score_v13e_t23(row: dict[str, Any]) -> float:
    """V13E_T23 = V13E + all tier2+tier3 bonuses.

    Full bonus stack: hurst + entropy + sector momentum (tier2) +
    OU + gravitational pull + market temperature (tier3).
    Note: use_tier2_bonuses includes entropy, so no separate use_entropy_bonus needed.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
        use_tier3_bonuses=True,
    )


def compute_return_max_long_score_v13e_qsc(row: dict[str, Any]) -> float:
    """V13E_QSC = V13E + Quantum Signal Coherence (multiplicative).

    Physics: quantum wave function coherence / constructive interference.
    7 binary signals (reaction, close_location, volume, doc_quality,
    guidance, entropy, hurst) → coherence fraction → ×0.85 to ×1.15 scaler.
    Measures second-order AGREEMENT across signals, orthogonal to base score.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_coherence_scaling=True,
    )


def compute_return_max_long_score_v13e_btg(row: dict[str, Any]) -> float:
    """V13E_BTG = V13E + Boltzmann Temperature Gate (multiplicative).

    Physics: statistical mechanics Boltzmann distribution — low temperature
    → ordered ground state, high temperature → excited/chaotic state.
    Market temperature = vol_5d/vol_20d.
    Cold (<0.8) → ×1.10, hot (>1.3) → ×0.85, normal → ×1.0.
    Affects 58% of trades; 2–3× stronger than prior ±0.03 additive bonus.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_temperature_gate=True,
    )


def compute_return_max_long_score_v13e_hug(row: dict[str, Any]) -> float:
    """V13E_HUG = V13E + Heisenberg Uncertainty Gate (multiplicative).

    Physics: Heisenberg uncertainty principle Δx·Δp ≥ ℏ/2 — simultaneous
    precision in two conjugate quantities is limited.
    Here: entropy (information uncertainty) × temperature (volatility uncertainty).
    High product (>2.5, 14.8% of trades) → dual uncertainty → ×0.85.
    Low product (<1.0, 14.8% of trades) → dual certainty → ×1.10.
    Captures INTERACTION between features, not their independent additive effects.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_uncertainty_gate=True,
    )


def compute_return_max_long_score_v16(row: dict[str, Any]) -> float:
    """V16 = V13E + market cap bonus. Larger caps have stronger PEAD (data-validated)."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_entropy_bonus=True,
        use_mcap_bonus=True,
    )


def compute_return_max_long_score_v16e(row: dict[str, Any]) -> float:
    """V16E = V5 + market cap bonus only (no entropy)."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_mcap_bonus=True,
    )


def compute_return_max_long_score_v13s(row: dict[str, Any]) -> float:
    """V13S = V5 + sector tailwind bonus only."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_sector_bonus=True,
    )


def compute_return_max_long_score_v12(row: dict[str, Any]) -> float:
    """V12 = V5 + technical indicator adjustments (RSI, BB, OBV).

    Soft adjustments to score based on pre-event technical state:
    - RSI > 70 (overbought): -10% penalty (mean reversion risk)
    - BB %B > 1.0 (above upper band): -15% penalty
    - RSI < 30 + positive reaction (oversold reversal): +5% bonus
    - OBV slope > 0 (accumulation): +3% bonus
    - OBV slope < -0.5 (distribution): -2% penalty
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_technical_gates=True,
    )


def compute_return_max_long_score_v12r(row: dict[str, Any]) -> float:
    """V12R = V5 + RSI penalty only (no BB/OBV)."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_rsi_penalty=True,
    )


def compute_return_max_long_score_v12b(row: dict[str, Any]) -> float:
    """V12B = V5 + BB penalty only."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_bb_penalty=True,
    )


def compute_return_max_long_score_v12o(row: dict[str, Any]) -> float:
    """V12O = V5 + OBV conviction bonus only."""
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_obv_bonus=True,
    )


def _compute_return_max_long_score(
    row: dict[str, Any],
    *,
    earnings_reaction_fallback: bool,
    use_signal_strength_proxy: bool,
    weights: dict[str, float],
    allow_generic_material_events: bool = False,
    use_zone_scoring: bool = False,
    use_market_confirmed_gate: bool = False,
    use_financial_bonus: bool = False,
    prior_drift_weight: float = 0.0,
    macro_regime_weight: float = 0.0,
    positive_only_aux_bonus: bool = False,
    use_earnings_surprise_bonus: bool = False,
    use_technical_gates: bool = False,
    use_rsi_penalty: bool = False,
    use_bb_penalty: bool = False,
    use_obv_bonus: bool = False,
    use_tier2_bonuses: bool = False,
    use_hurst_bonus: bool = False,
    use_entropy_bonus: bool = False,
    use_sector_bonus: bool = False,
    use_tier3_bonuses: bool = False,
    use_ou_bonus: bool = False,
    use_grav_bonus: bool = False,
    use_temp_bonus: bool = False,
    use_contrarian_obv: bool = False,
    use_contrarian_obv_strong: bool = False,
    use_contrarian_bb: bool = False,
    use_contrarian_rsi: bool = False,
    use_mcap_bonus: bool = False,
    use_coherence_scaling: bool = False,
    use_temperature_gate: bool = False,
    use_uncertainty_gate: bool = False,
) -> float:
    """Long-biased score for return-max event strategies.

    Hard gates are applied before the score is computed. Scores are in [0, 1].
    Missing wiki/attention data is treated as neutral (no overheat penalty).
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type == "management_change":
        return _compute_management_change_score(row)
    if allow_generic_material_events and event_type in {"material_contract", "other_material_event", "unknown"}:
        return _compute_generic_material_event_score(row)
    if event_type not in {"earnings_release", "guidance_update"}:
        return 0.0

    event_direction = str(row.get("event_direction", "unknown")).lower()
    reaction_day_return = _safe_float(row.get("reaction_day_return"))
    if event_type == "earnings_release" and earnings_reaction_fallback:
        if event_direction == "bearish":
            return 0.0
        if event_direction != "bullish" and (reaction_day_return is None or reaction_day_return <= 0.0):
            return 0.0
    elif event_direction != "bullish":
        return 0.0

    parse_conf_overall = _safe_float(row.get("parse_confidence_overall"))
    parse_gate = 0.60
    if (
        use_market_confirmed_gate
        and event_type == "earnings_release"
        and reaction_day_return is not None and reaction_day_return > 0.05
    ):
        cl = _safe_float(row.get("close_location"))
        vol = _safe_float(row.get("volume_ratio_20d"))
        if cl is not None and cl > 0.55 and vol is not None and vol > 1.3:
            parse_gate = 0.45
    if parse_conf_overall is None or parse_conf_overall < parse_gate:
        return 0.0

    oneoff_penalty = _safe_float(row.get("oneoff_penalty"))
    if oneoff_penalty is None or oneoff_penalty >= 0.40:
        return 0.0

    parse_conf_direction = _safe_float(row.get("parse_confidence_event_direction"))
    if parse_conf_direction is None:
        parse_conf_direction = parse_conf_overall

    guidance_conf = _safe_float(row.get("parse_confidence_guidance"))
    if guidance_conf is None:
        guidance_conf = parse_conf_overall

    if event_type == "guidance_update":
        guidance_status = str(row.get("guidance_status", "")).lower()
        if guidance_status != "raised" or guidance_conf < 0.70:
            return 0.0

    document_quality = _normalized(row.get("document_quality_score"))
    if use_signal_strength_proxy:
        document_quality = max(document_quality, _normalized(row.get("signal_strength_score")))

    document_component = (
        document_quality * weights["document_quality"]
        + _normalized(row.get("guidance_direction_score")) * weights["guidance_direction"]
        + (1.0 - _normalized(row.get("oneoff_penalty"))) * weights["oneoff_inverse"]
        + _normalized(parse_conf_overall) * weights["parse_overall"]
        + _normalized(parse_conf_direction) * weights["parse_direction"]
    )

    if use_zone_scoring:
        market_component = (
            _zone_reaction_score(row.get("reaction_day_return")) * weights["reaction"]
            + _zone_close_location_score(row.get("close_location")) * weights["close_location"]
            + _zone_volume_score(row.get("volume_ratio_20d")) * weights["volume"]
            + _gap_quality_score(row) * weights["gap"]
        )
        conviction = _safe_float(row.get("institutional_conviction_score"))
        if conviction is not None:
            market_component += conviction * 0.03
    else:
        market_component = (
            _normalize_linear(row.get("reaction_day_return"), 0.03, 0.12) * weights["reaction"]
            + _normalize_linear(row.get("close_location"), 0.60, 0.90) * weights["close_location"]
            + _normalize_linear(row.get("volume_ratio_20d"), 1.0, 2.5) * weights["volume"]
            + _gap_quality_score(row) * weights["gap"]
        )

    positive_weight_sum = sum(weights.values())
    if positive_weight_sum <= 0.0:
        return 0.0
    raw = (document_component + market_component) / positive_weight_sum - _overheat_penalty(row)

    if use_financial_bonus:
        raw += _financial_bonus_score(row) * 0.07

    if prior_drift_weight > 0.0:
        prior_signal = _prior_drift_momentum_score(row)
        if positive_only_aux_bonus:
            prior_signal = max(0.0, prior_signal)
        raw += prior_signal * prior_drift_weight

    if macro_regime_weight > 0.0:
        macro_signal = _macro_regime_score(row)
        if positive_only_aux_bonus:
            macro_signal = max(0.0, macro_signal)
        raw += macro_signal * macro_regime_weight

    if use_earnings_surprise_bonus:
        raw += _earnings_surprise_bonus(row) * 0.10

    # Technical indicator adjustments
    if use_technical_gates or use_rsi_penalty:
        rsi = _safe_float(row.get("pre_event_rsi_14"))
        if rsi is not None:
            if rsi > 70:
                raw *= 0.90  # overbought penalty
            elif rsi < 30:
                reaction = _safe_float(row.get("reaction_day_return"))
                if reaction is not None and reaction > 0:
                    raw *= 1.05  # oversold reversal bonus

    if use_technical_gates or use_bb_penalty:
        bb = _safe_float(row.get("pre_event_bb_position"))
        if bb is not None and bb > 1.0:
            raw *= 0.85  # above upper band penalty

    if use_technical_gates or use_obv_bonus:
        obv = _safe_float(row.get("pre_event_obv_slope_20d"))
        if obv is not None:
            if obv > 0:
                raw += 0.03  # accumulation bonus
            elif obv < -0.5:
                raw -= 0.02  # strong distribution penalty

    # Tier 2 bonuses: Hurst, Entropy, Sector
    if use_tier2_bonuses or use_hurst_bonus:
        hurst = _safe_float(row.get("pre_event_hurst_60d"))
        if hurst is not None:
            if hurst > 0.55:
                raw += 0.03  # trending = PEAD friendly
            elif hurst < 0.45:
                raw -= 0.02  # mean-reverting = PEAD unfriendly

    if use_tier2_bonuses or use_entropy_bonus:
        entropy = _safe_float(row.get("pre_event_entropy_60d"))
        if entropy is not None:
            # Lower entropy = more predictable. Typical range ~1.5-2.3
            if entropy < 1.8:
                raw += 0.03  # predictable patterns bonus
            elif entropy > 2.2:
                raw -= 0.02  # chaotic = harder to predict

    if use_tier2_bonuses or use_sector_bonus:
        sector_mom = _safe_float(row.get("pre_event_sector_momentum_20d"))
        if sector_mom is not None:
            if sector_mom > 0.02:
                raw += 0.03  # strong sector tailwind
            elif sector_mom > 0:
                raw += 0.01  # mild tailwind
            elif sector_mom < -0.02:
                raw -= 0.02  # sector headwind

    # Tier 3 bonuses: OU theta, Gravitational Pull, Market Temperature
    if use_tier3_bonuses or use_ou_bonus:
        ou = _safe_float(row.get("pre_event_ou_theta_60d"))
        if ou is not None:
            if ou < 0.02:
                raw += 0.03  # very slow reversion = PEAD friendly
            elif ou < 0.05:
                raw += 0.01  # slow reversion
            elif ou > 0.15:
                raw -= 0.02  # fast reversion = PEAD unfriendly

    if use_tier3_bonuses or use_grav_bonus:
        gp = _safe_float(row.get("pre_event_gravitational_pull"))
        if gp is not None:
            if gp < 0.5:
                raw += 0.03  # near MAs = stable = PEAD friendly
            elif gp < 1.0:
                raw += 0.01  # reasonably close
            elif gp > 2.0:
                raw -= 0.02  # far from MAs = strong pull back risk

    if use_tier3_bonuses or use_temp_bonus:
        temp = _safe_float(row.get("pre_event_market_temperature"))
        if temp is not None:
            if temp < 0.8:
                raw += 0.03  # cooling = orderly = PEAD friendly
            elif temp < 1.0:
                raw += 0.01  # mildly cool
            elif temp > 1.5:
                raw -= 0.02  # overheating = chaotic

    # Market cap bonus: larger companies have stronger/more predictable PEAD
    # Data: OME winners avg $348B vs losers $173B; mat_unknown winners $117B vs losers $43B
    if use_mcap_bonus:
        mcap = _safe_float(row.get("market_cap_proxy"))
        if mcap is not None:
            if mcap > 50e9:
                raw += 0.04  # mega-cap: strongest PEAD signal
            elif mcap > 20e9:
                raw += 0.02  # large-cap: good signal
            elif mcap < 5e9:
                raw -= 0.02  # small-cap: weaker/noisier PEAD

    # Contrarian bonuses: REVERSED direction based on data analysis
    # OBV Q1 (distribution) = 56.4% WR vs Q5 (accumulation) = 51.2%
    if use_contrarian_obv or use_contrarian_obv_strong:
        obv = _safe_float(row.get("pre_event_obv_slope_20d"))
        if obv is not None:
            boost = 0.08 if use_contrarian_obv_strong else 0.04
            if obv < -0.0085:  # Q1 threshold: strong distribution
                raw += boost
            elif obv < 0:  # mild distribution
                raw += boost * 0.5
            elif obv > 0.01:  # accumulation = actually WORSE for PEAD
                raw -= 0.02

    # BB Q1 (below midline) = 54.9% WR — room to run after positive event
    if use_contrarian_bb:
        bb = _safe_float(row.get("pre_event_bb_position"))
        if bb is not None:
            if bb < 0.46:  # Q1 threshold
                raw += 0.03
            elif bb < 0.5:  # below midline
                raw += 0.01
            elif bb > 1.0:  # above upper band = overextended
                raw -= 0.02

    # RSI contrarian: low RSI (oversold) = better PEAD (55.4% WR)
    if use_contrarian_rsi:
        rsi = _safe_float(row.get("pre_event_rsi_14"))
        if rsi is not None:
            if rsi < 40:  # oversold zone
                raw += 0.03
            elif rsi < 50:  # below midline
                raw += 0.01
            elif rsi > 70:  # overbought = PEAD weaker
                raw -= 0.02

    # === Physics-inspired multiplicative scaling ===
    # Unlike additive bonuses (±0.02-0.05), these scale the ENTIRE raw score
    # by ±15%, which is large enough to reorder candidates near the threshold.

    if use_coherence_scaling:
        # Quantum Signal Coherence: measure agreement of 7 binary signals.
        # When all signals align (constructive interference), boost score.
        # When signals disagree (destructive interference), reduce score.
        # coherence = fraction of signals that are "positive"
        # adjusted = raw * (1 + 0.15 * (2*coherence - 1)) → ×0.85 to ×1.15
        reaction_v = _safe_float(row.get("reaction_day_return"))
        close_v = _safe_float(row.get("close_location"))
        vol_v = _safe_float(row.get("volume_ratio_20d"))
        dq_v = _safe_float(row.get("document_quality_score"))
        gd_v = _safe_float(row.get("guidance_direction_score"))
        ent_v = _safe_float(row.get("pre_event_entropy_60d"))
        hurst_v = _safe_float(row.get("pre_event_hurst_60d"))
        coherent_signals = [
            reaction_v is not None and reaction_v > 0.05,
            close_v is not None and close_v > 0.70,
            vol_v is not None and vol_v > 1.5,
            dq_v is not None and dq_v > 0.70,
            gd_v is not None and gd_v > 0.50,
            ent_v is not None and ent_v < 1.8,
            hurst_v is not None and hurst_v > 0.55,
        ]
        coherence = sum(coherent_signals) / len(coherent_signals)
        raw *= 1.0 + 0.15 * (2.0 * coherence - 1.0)

    if use_temperature_gate:
        # Boltzmann Temperature Gate: cold markets → higher confidence,
        # hot/chaotic markets → lower confidence.
        # temp = vol_5d / vol_20d; cold(<0.8)×1.10, hot(>1.3)×0.85
        temp_v = _safe_float(row.get("pre_event_market_temperature"))
        if temp_v is not None:
            if temp_v < 0.8:
                raw *= 1.10
            elif temp_v > 1.3:
                raw *= 0.85

    if use_uncertainty_gate:
        # Heisenberg Uncertainty Gate: high entropy × high temperature → dual
        # uncertainty reduces prediction confidence (multiplicative interaction).
        # uncertainty_product > 2.5 → ×0.85; < 1.0 → ×1.10
        ent_v2 = _safe_float(row.get("pre_event_entropy_60d"))
        temp_v2 = _safe_float(row.get("pre_event_market_temperature"))
        if ent_v2 is not None and temp_v2 is not None:
            uncertainty = ent_v2 * temp_v2
            if uncertainty > 2.5:
                raw *= 0.85
            elif uncertainty < 1.0:
                raw *= 1.10

    return _clamp(raw)


def _compute_management_change_score(row: dict[str, Any]) -> float:
    """Score management_change events for long-side PEAD.

    Empirically validated on 343 train events (2022-2025):
      - Bullish reaction MC: 65.2% win rate at 5d, +0.23% mean
      - close_location >= 0.5 is the strongest single filter (69.7% WR)
      - Volume filtering hurts (opposite of earnings) — minimal weight

    Hard gates:
      - reaction_day_return > 0 (bullish market reaction only)
      - parse_confidence_overall >= 0.50
      - oneoff_penalty < 0.40

    Components:
      Document (35%): document_quality (20%), parse_confidence (10%),
                      risk penalty (5%)
      Market   (65%): close_location (30%), reaction_magnitude (20%),
                      gap_quality (10%), volume (5%)
    """
    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction <= 0.0:
        return 0.0

    parse_conf = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf is None or parse_conf < 0.50:
        return 0.0

    oneoff = _safe_float(row.get("oneoff_penalty"))
    if oneoff is None or oneoff >= 0.40:
        return 0.0

    # --- Document component (35%) ---
    document_component = (
        _normalized(row.get("document_quality_score")) * 0.20
        + _normalized(parse_conf) * 0.10
        + (1.0 - _normalized(oneoff)) * 0.05
    )

    # --- Market component (65%) ---
    # close_location is the strongest MC signal (69.7% WR when >= 0.5)
    # Reaction: 0-2% is the sweet spot; larger reactions have poor follow-through
    # Volume: minimal weight (filtering hurts MC unlike earnings)
    market_component = (
        _normalize_linear(row.get("close_location"), 0.40, 0.80) * 0.30
        + _normalize_linear(reaction, 0.0, 0.05) * 0.20
        + _gap_quality_score(row) * 0.10
        + _normalize_linear(row.get("volume_ratio_20d"), 0.8, 2.0) * 0.05
    )

    raw = document_component + market_component
    return _clamp(raw)


def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
    return max(low, min(high, value))


def _normalize_linear(raw: Any, low: float, high: float) -> float:
    value = _safe_float(raw)
    if value is None:
        return 0.0
    if high <= low:
        return 0.0
    return _clamp((value - low) / (high - low))


def _normalized(raw: Any) -> float:
    value = _safe_float(raw)
    if value is None:
        return 0.0
    return _clamp(value)


def _gap_quality_score(row: dict[str, Any]) -> float:
    gap = _safe_float(row.get("gap_size"))
    if gap is None:
        return 0.5
    if gap < 0.0:
        return 0.0
    if gap <= 0.02:
        return 1.0
    if gap >= 0.05:
        return 0.0
    return _clamp(1.0 - (gap - 0.02) / 0.03)


def _zone_reaction_score(raw: Any) -> float:
    """Non-linear reaction scoring: peak at 8-20%, drops at 25%+.

    Empirical zones from data analysis:
      - 0-3%: weak signal, ramp 0.2 -> 0.5
      - 3-8%: moderate, ramp 0.5 -> 0.85
      - 8-20%: optimal zone, plateau 0.85 -> 1.0
      - 20-25%: cooling, drop 1.0 -> 0.40
      - 25%+: mean-reversion risk, floor 0.15
    """
    value = _safe_float(raw)
    if value is None:
        return 0.0
    r = abs(value)
    if r < 0.03:
        return 0.20 + (r / 0.03) * 0.30
    if r < 0.08:
        return 0.50 + ((r - 0.03) / 0.05) * 0.35
    if r <= 0.20:
        return 0.85 + ((r - 0.08) / 0.12) * 0.15
    if r <= 0.25:
        return 1.0 - ((r - 0.20) / 0.05) * 0.60
    return 0.15


def _zone_close_location_score(raw: Any) -> float:
    """Non-linear close location scoring: peak at 80-90%, exhaustion at 92%+.

    Empirical zones:
      - 0-60%: weak, ramp 0.10 -> 0.40
      - 60-70%: moderate, dip zone 0.40 -> 0.55
      - 70-80%: building, ramp 0.55 -> 0.85
      - 80-90%: optimal zone, plateau 0.85 -> 1.0
      - 90-92%: transition 1.0 -> 0.55
      - 92%+: exhaustion, floor 0.20
    """
    value = _safe_float(raw)
    if value is None:
        return 0.0
    if value < 0.60:
        return 0.10 + (value / 0.60) * 0.30
    if value < 0.70:
        return 0.40 + ((value - 0.60) / 0.10) * 0.15
    if value < 0.80:
        return 0.55 + ((value - 0.70) / 0.10) * 0.30
    if value <= 0.90:
        return 0.85 + ((value - 0.80) / 0.10) * 0.15
    if value <= 0.92:
        return 1.0 - ((value - 0.90) / 0.02) * 0.45
    return 0.20


def _zone_volume_score(raw: Any) -> float:
    """Non-linear volume scoring: peak at 3-5x, drops at 6x+.

    Empirical zones:
      - 0-1x: below average, floor 0.15
      - 1-2x: normal conviction, ramp 0.15 -> 0.55
      - 2-3x: building, ramp 0.55 -> 0.85
      - 3-5x: optimal institutional zone, plateau 0.85 -> 1.0
      - 5-6x: cooling, drop 1.0 -> 0.35
      - 6x+: overhyped, floor 0.10
    """
    value = _safe_float(raw)
    if value is None:
        return 0.0
    if value < 1.0:
        return 0.15
    if value < 2.0:
        return 0.15 + ((value - 1.0) / 1.0) * 0.40
    if value < 3.0:
        return 0.55 + ((value - 2.0) / 1.0) * 0.30
    if value <= 5.0:
        return 0.85 + ((value - 3.0) / 2.0) * 0.15
    if value <= 6.0:
        return 1.0 - ((value - 5.0) / 1.0) * 0.65
    return 0.10


def _exhaustion_penalty(row: dict[str, Any]) -> float:
    """Penalty for empirically identified exhaustion zones.

    Applied on top of linear scoring to penalize entries in zones
    where follow-through is poor, without changing the core scoring.

    Zones (from data analysis):
      - Close >= 93%: exhaustion buying (-0.15% mean OOS)
      - Reaction >= 22%: mean-reversion risk
      - Volume >= 6x: overhyped crowd behavior (46% WR)
    """
    penalty = 0.0

    cl = _safe_float(row.get("close_location"))
    if cl is not None:
        if cl >= 0.92:
            penalty += _normalize_linear(cl, 0.92, 1.0) * 0.05
        elif cl < 0.60:
            penalty += (1.0 - _normalize_linear(cl, 0.40, 0.60)) * 0.02

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is not None and abs(reaction) >= 0.20:
        penalty += _normalize_linear(abs(reaction), 0.20, 0.30) * 0.04

    vol = _safe_float(row.get("volume_ratio_20d"))
    if vol is not None and vol >= 5.0:
        penalty += _normalize_linear(vol, 5.0, 8.0) * 0.03

    return penalty


def _macro_regime_score(row: dict[str, Any]) -> float:
    """Score from macro regime: VIX level and HY credit spread.

    Empirical finding on 2,648 earnings events:
    - VIX > 18 AND HY > 3.25: 62.3% WR, +1.61% mean (favorable)
    - Otherwise: 50.8% WR, +0.09% mean (neutral/adverse)

    Returns [-0.5, +1.0]; caller scales by weight (e.g. 0.12 = 12%).
    """
    vix = _safe_float(row.get("macro_vix"))
    hy = _safe_float(row.get("macro_hy_spread"))

    if vix is None:
        return 0.0

    # Favorable: high VIX + wide HY = fear regime, PEAD strongest
    if vix > 18 and hy is not None and hy > 3.25:
        return min(1.0, (vix - 18) / 10)  # 0.0 at VIX=18, 1.0 at VIX=28

    # Adverse: mid VIX (15-18) = complacent, PEAD weakest
    if 15 < vix <= 18:
        return -0.5

    return 0.0


def _prior_drift_momentum_score(row: dict[str, Any]) -> float:
    """Score from prior same-ticker event's 5d forward return (cross-event momentum).

    Empirical finding: prior positive drift (>+2%) predicts 56.8% WR on next event
    vs 48.7% WR when prior drift was negative (<-2%). 8.1pp spread, 0.87% mean
    return spread across 10,729 events.

    Returns [-1.0, +1.0]; caller scales by desired weight (e.g. 0.10 = 10%).
    Positive when prior drift was positive, negative when prior drift was negative.
    """
    prior = _safe_float(row.get("prior_event_fwd5d"))
    if prior is None:
        return 0.0

    if prior > 0.02:
        return min(1.0, prior / 0.06)
    elif prior < -0.02:
        return max(-1.0, prior / 0.06)
    else:
        return 0.0


def _financial_bonus_score(row: dict[str, Any]) -> float:
    """Conditional bonus from financial features (eps_growth_qoq, revenue_growth_qoq).

    Only active for earnings_release events with non-null financial data.
    Returns 0.0-1.0; caller scales by desired weight (e.g. 0.07 = 7%).
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type != "earnings_release":
        return 0.0

    eps = _safe_float(row.get("eps_growth_qoq"))
    rev = _safe_float(row.get("revenue_growth_qoq"))

    if eps is None and rev is None:
        return 0.0

    bonus = 0.0
    if eps is not None and eps > 0:
        bonus += min(1.0, eps / 0.20) * 0.6
    if rev is not None and rev > 0:
        bonus += min(1.0, rev / 0.15) * 0.4

    return min(1.0, bonus)


def _earnings_surprise_bonus(row: dict[str, Any]) -> float:
    """Bonus from earnings surprise (actual vs estimated EPS).

    Empirical finding: small beats (0-3%) have 82.4% WR vs 54.8% for big beats.
    Moderate surprise creates strongest PEAD (gradual repricing).
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type != "earnings_release":
        return 0.0

    surprise = _safe_float(row.get("earnings_surprise_pct"))
    if surprise is None:
        return 0.0

    if 0 < surprise <= 3:
        return 1.0  # sweet spot: moderate beat
    elif 3 < surprise <= 8:
        return 0.5  # decent beat but partially priced in
    elif surprise > 8:
        return 0.0  # big beat = already priced in
    elif surprise <= 0:
        return -0.5  # miss = penalty

    return 0.0


def _overheat_penalty(row: dict[str, Any]) -> float:
    penalties: list[float] = []

    wiki_spike = _safe_float(row.get("attention_wiki_spike_10d"))
    if wiki_spike is not None and wiki_spike > 1.0:
        penalties.append(_normalize_linear(wiki_spike, 1.5, 4.0))

    gap = _safe_float(row.get("gap_size"))
    if gap is not None and gap > 0.02:
        penalties.append(_normalize_linear(gap, 0.02, 0.08))

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is not None and reaction > 0.12:
        penalties.append(_normalize_linear(reaction, 0.12, 0.20))

    if not penalties:
        return 0.0
    return statistics_mean(penalties) * 0.10


def statistics_mean(values: list[float]) -> float:
    return sum(values) / len(values) if values else 0.0


def _safe_float(raw: Any) -> float | None:
    try:
        return float(raw)
    except (TypeError, ValueError):
        return None


# ---------------------------------------------------------------------------
# Market feature scoring
# ---------------------------------------------------------------------------


def _reaction_score(row: dict[str, Any]) -> float:
    """Score based on reaction_day_return direction.

    Simple directional mapping — no "ideal zone" assumptions.
    Empirical analysis showed the prior PEAD-zone mapping was anti-signal.

    Mapping:
      > +3%         -> 0.7  (strong positive)
      > 0%          -> 0.6  (positive)
      0% to -2%     -> 0.45 (flat to mild negative)
      -2% to -5%    -> 0.3  (moderate negative)
      < -5%         -> 0.2  (strongly negative)
    """
    rdr = row.get("reaction_day_return")
    if rdr is None:
        return 0.5

    r = float(rdr)
    if r > 0.03:
        return 0.7
    elif r > 0.0:
        return 0.6
    elif r >= -0.02:
        return 0.45
    elif r >= -0.05:
        return 0.3
    else:  # r < -0.05
        return 0.2


def _close_strength_score(row: dict[str, Any]) -> float:
    """Score based on close_location [0=low, 1=high].

    Linear mapping: 0.0 -> 0.1, 1.0 -> 1.0.
    Close near session high = buyers controlled the day.
    """
    cl = row.get("close_location")
    if cl is None:
        return 0.5

    c = max(0.0, min(1.0, float(cl)))
    return 0.1 + 0.9 * c


def _volume_score(row: dict[str, Any]) -> float:
    """Score based on volume_ratio_20d.

    Above-average volume confirms conviction, but extreme volume
    (>3x) can signal exhaustion or panic, so it gets discounted.

    Mapping:
      1.2x-2.0x -> 0.8  (healthy conviction)
      1.0x-1.2x -> 0.6  (normal)
      2.0x-3.0x -> 0.55 (high — possible exhaustion)
      > 3.0x    -> 0.4  (extreme — likely exhaustion)
      < 1.0x    -> 0.3  (below average — no conviction)
    """
    vr = row.get("volume_ratio_20d")
    if vr is None:
        return 0.5

    v = float(vr)
    if 1.2 <= v <= 2.0:
        return 0.8
    elif 1.0 <= v < 1.2:
        return 0.6
    elif 2.0 < v <= 3.0:
        return 0.55
    elif v > 3.0:
        return 0.4
    else:  # v < 1.0
        return 0.3


def _gap_score(row: dict[str, Any]) -> float:
    """Score based on gap_size (open vs previous close).

    Small positive gap (0-2%) = orderly bullish opening.
    Large gap (>5%) = potential exhaustion gap.
    Negative gap = bearish opening pressure.

    Mapping:
      +0.5% to +2%  -> 0.8  (orderly strength)
      0%    to +0.5% -> 0.6  (neutral-to-mild)
      +2%   to +5%  -> 0.5  (getting extended)
      > +5%         -> 0.3  (exhaustion gap risk)
      -2%   to  0%  -> 0.4  (mild weakness)
      < -2%         -> 0.2  (bearish gap)
    """
    gs = row.get("gap_size")
    if gs is None:
        return 0.5

    g = float(gs)
    if 0.005 <= g <= 0.02:
        return 0.8
    elif 0.0 <= g < 0.005:
        return 0.6
    elif 0.02 < g <= 0.05:
        return 0.5
    elif g > 0.05:
        return 0.3
    elif -0.02 <= g < 0.0:
        return 0.4
    else:  # g < -0.02
        return 0.2


# ---------------------------------------------------------------------------
# Earnings surprise (SUE) scoring
# ---------------------------------------------------------------------------


def _earnings_surprise_score(row: dict[str, Any]) -> float:
    """Score based on earnings surprise (eps_growth_qoq as naive SUE proxy).

    Only active for earnings_release events; returns neutral 0.5 for others.
    Bernard & Thomas (1989): drift magnitude is proportional to surprise.

    Mapping:
      > +20% EPS growth  -> 0.9  (strong beat)
      > +5%              -> 0.75 (moderate beat)
      > -5%              -> 0.5  (in-line)
      > -20%             -> 0.25 (moderate miss)
      <= -20%            -> 0.1  (severe miss)
    """
    event_type = row.get("event_type", "")
    if event_type != "earnings_release":
        return 0.5

    sue = row.get("eps_growth_qoq")
    if sue is None:
        return 0.5

    s = float(sue)
    if s > 0.20:
        return 0.9
    if s > 0.05:
        return 0.75
    if s > -0.05:
        return 0.5
    if s > -0.20:
        return 0.25
    return 0.1


# ---------------------------------------------------------------------------
# Event feature scoring
# ---------------------------------------------------------------------------


def _event_quality_score(row: dict[str, Any]) -> float:
    """Score based on event parsing quality and signal strength.

    Combines:
      - document_quality_score [0-1]: parser confidence in the extraction
      - signal_strength_score [0-1]: strength of business fundamentals signals
      - guidance_direction_score: 1.0=raised, 0.5=inline, 0.25=unclear, 0.0=lowered

    When event features are absent (market_v1 only Parquet), returns 0.5.
    """
    doc_q = row.get("document_quality_score")
    sig_s = row.get("signal_strength_score")
    guid = row.get("guidance_direction_score")

    # If no event features at all, return neutral
    if doc_q is None and sig_s is None and guid is None:
        return 0.5

    # Weight: signal_strength 40%, guidance 35%, document_quality 25%
    scores = []
    weights = []

    if sig_s is not None:
        scores.append(float(sig_s))
        weights.append(0.40)

    if guid is not None:
        scores.append(float(guid))
        weights.append(0.35)

    if doc_q is not None:
        scores.append(float(doc_q))
        weights.append(0.25)

    if not scores:
        return 0.5

    total_weight = sum(weights)
    return sum(s * w for s, w in zip(scores, weights)) / total_weight


def _risk_penalty_score(row: dict[str, Any]) -> float:
    """Score based on oneoff_penalty (risk flags).

    oneoff_penalty [0-1]: fraction of active risk flags.
    Higher penalty = lower score (more risk = less favorable entry).

    Inverted: 0.0 penalty -> 0.9 score, 1.0 penalty -> 0.2 score.
    When absent, returns neutral 0.5.
    """
    penalty = row.get("oneoff_penalty")
    if penalty is None:
        return 0.5

    p = max(0.0, min(1.0, float(penalty)))
    # Linear inversion: 0 -> 0.9, 1.0 -> 0.2
    return 0.9 - 0.7 * p


def _parse_confidence_score(row: dict[str, Any]) -> float:
    """Score based on parse_confidence_overall [0-1]."""
    conf = row.get("parse_confidence_overall")
    if conf is None:
        return 0.5
    c = float(conf)
    if c > 0.8:
        return 0.9
    if c > 0.6:
        return 0.7
    if c > 0.5:
        return 0.5
    if c > 0.4:
        return 0.3
    return 0.2


def _direction_clarity_score(row: dict[str, Any]) -> float:
    """Score based on event_direction categorical field."""
    direction = row.get("event_direction")
    if direction is None:
        return 0.5
    return {"bullish": 0.9, "mixed": 0.4, "neutral": 0.3,
            "bearish": 0.1, "unknown": 0.2}.get(str(direction).lower(), 0.5)


# ---------------------------------------------------------------------------
# Text sentiment scoring
# ---------------------------------------------------------------------------


def compute_patient_drift_score(row: dict[str, Any]) -> float:
    """Market-only score for patient drift execution model.

    Designed from MFE/MAE empirical analysis: NLP features have zero
    forward-return predictive power, so only market confirmation signals
    are used.  Paired with a patient execution model (fixed 4% stop,
    no target, trailing + time exit, no early failure, no partials).

    Hard gates (all must pass):
      - event_type in {earnings_release, guidance_update}
      - reaction_day_return > 0
      - parse_confidence_overall >= 0.50
      - oneoff_penalty < 0.40

    Weighted components (100% market):
      - Reaction magnitude  (40%)
      - Close location       (35%)
      - Volume conviction    (15%)
      - Gap alignment        (10%)

    Returns float in [0.0, 1.0].  Returns 0.0 if any gate fails.
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type not in {"earnings_release", "guidance_update"}:
        return 0.0

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction <= 0.0:
        return 0.0

    parse_conf = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf is None or parse_conf < 0.50:
        return 0.0

    oneoff = _safe_float(row.get("oneoff_penalty"))
    if oneoff is None or oneoff >= 0.40:
        return 0.0

    # --- Component 1: Reaction magnitude (40%) ---
    # Linear: 0% -> 0.0, 3% -> 0.5, 8% -> 0.85, 15%+ -> 1.0
    reaction_component = _normalize_linear(reaction, 0.0, 0.15)

    # --- Component 2: Close location (35%) ---
    # Linear: 0.40 -> 0.0, 0.90 -> 1.0
    close_component = _normalize_linear(row.get("close_location"), 0.40, 0.90)

    # --- Component 3: Volume conviction (15%) ---
    # Linear: 1.0 -> 0.0, 2.5 -> 1.0; below 1.0 gets 0
    volume_component = _normalize_linear(row.get("volume_ratio_20d"), 1.0, 2.5)

    # --- Component 4: Gap alignment (10%) ---
    gap_component = _gap_quality_score(row)

    raw = (
        reaction_component * 0.40
        + close_component * 0.35
        + volume_component * 0.15
        + gap_component * 0.10
    )
    return _clamp(raw)


def compute_microstructure_score(row: dict[str, Any]) -> float:
    """Event-agnostic score based on pure price microstructure signals.

    Hypothesis: when the market reacts "quietly but decisively" to any
    catalyst (positive return, close near high, below-average volume),
    information asymmetry exists and subsequent drift follows.

    No event_type gate — all events are eligible.

    Hard gates:
      - reaction_day_return must exist and > 0

    Weighted components:
      - Close location      (35%) — close near high = decisive buying
      - Reaction magnitude   (30%) — positive confirms direction
      - Volume quietness     (20%) — below-average volume = informed, not crowd
      - Gap alignment        (15%) — small positive gap = orderly

    Returns float in [0.0, 1.0].  Returns 0.0 if gate fails.
    """
    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction <= 0.0:
        return 0.0

    # --- Component 1: Close location (35%) ---
    # Higher close = more decisive buying
    close_component = _normalize_linear(row.get("close_location"), 0.50, 0.95)

    # --- Component 2: Reaction magnitude (30%) ---
    # Moderate reactions (2-8%) are ideal; very large reactions may revert
    reaction_component = _normalize_linear(reaction, 0.0, 0.10)

    # --- Component 3: Volume quietness (20%) ---
    # INVERSE: below-average volume scores HIGH (quiet conviction)
    # vol < 0.5 -> 1.0, vol = 1.0 -> 0.5, vol > 2.0 -> 0.0
    vol = _safe_float(row.get("volume_ratio_20d"))
    if vol is None:
        volume_component = 0.5
    else:
        volume_component = _clamp(1.0 - _normalize_linear(vol, 0.5, 2.0))

    # --- Component 4: Gap alignment (15%) ---
    gap = _safe_float(row.get("gap_size"))
    if gap is None:
        gap_component = 0.5
    elif gap < 0:
        gap_component = 0.1  # negative gap contradicts positive reaction
    elif gap <= 0.02:
        gap_component = 1.0  # small positive gap = orderly
    elif gap <= 0.05:
        gap_component = 0.6  # moderate gap
    else:
        gap_component = 0.2  # large gap = exhaustion risk

    raw = (
        close_component * 0.35
        + reaction_component * 0.30
        + volume_component * 0.20
        + gap_component * 0.15
    )
    return _clamp(raw)


def _text_sentiment_score(row: dict[str, Any]) -> float:
    """Score based on Loughran-McDonald text sentiment features.

    Uses lm_net_sentiment (positive - negative word fraction).
    Typical range is [-0.02, +0.02] for SEC filings.

    Mapping:
      > +0.005  -> 0.8  (noticeably positive tone)
      > +0.001  -> 0.65 (mildly positive)
      > -0.001  -> 0.5  (neutral)
      > -0.005  -> 0.35 (mildly negative)
      <= -0.005 -> 0.2  (noticeably negative tone)

    When absent, returns neutral 0.5.
    """
    net = row.get("lm_net_sentiment")
    if net is None:
        return 0.5

    n = float(net)
    if n > 0.005:
        return 0.8
    if n > 0.001:
        return 0.65
    if n > -0.001:
        return 0.5
    if n > -0.005:
        return 0.35
    return 0.2




def compute_oversold_bounce_score(row: dict[str, Any]) -> float:
    """Score for oversold bounce after negative earnings reaction.
    
    Designed for contrarian mean-reversion trades where:
    - Large-cap stock drops on mixed/negative event
    - High volume confirms institutional selling (not just noise)
    - Market cap provides floor for recovery
    
    No direction or reaction-sign gates — the whole point is negative reaction.
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type not in {"earnings_release", "guidance_update", "other_material_event"}:
        return 0.0
    
    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction > -0.03:  # must be negative
        return 0.0
    if reaction < -0.25:  # too extreme, might be real bad news
        return 0.0
    
    parse_conf = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf is not None and parse_conf < 0.30:
        return 0.0
    
    # Score components for bounce quality
    # 1. Reaction magnitude (40%) — bigger drop = more bounce potential
    reaction_score = min(1.0, abs(reaction) / 0.15)
    
    # 2. Volume (30%) — high volume = institutional selling = stronger signal
    vol = _safe_float(row.get("volume_ratio_20d"))
    vol_score = min(1.0, (vol or 1.0) / 4.0) if vol else 0.3
    
    # 3. Close location (30%) — higher close = buying into close = bounce starting
    cl = _safe_float(row.get("close_location"))
    cl_score = _normalize_linear(cl, 0.1, 0.6) if cl is not None else 0.3
    
    raw = reaction_score * 0.40 + vol_score * 0.30 + cl_score * 0.30
    return _clamp(raw * 0.8)  # scale down to avoid competing with main strategy

def compute_return_max_long_score_v17(row: dict[str, Any]) -> float:
    """V17 = V13E + contrarian OBV only.

    Data analysis (6,560 train events): OBV Q1 (distribution) = 56.4% WR
    vs Q5 (accumulation) = 51.2%. Contrarian signal: stocks with pre-event
    institutional selling drift further after positive event.
    """
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_entropy_bonus=True, use_contrarian_obv=True,
    )


def compute_return_max_long_score_v17b(row: dict[str, Any]) -> float:
    """V17B = V13E + contrarian OBV + contrarian BB.

    OBV Q1 + BB Q1 combo = 58.1% WR (best dual signal).
    """
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_entropy_bonus=True, use_contrarian_obv=True, use_contrarian_bb=True,
    )


def compute_return_max_long_score_v17c(row: dict[str, Any]) -> float:
    """V17C = V13E + all contrarian signals (OBV + BB + RSI) + OU theta.

    Full contrarian reversal: every feature that was backwards is now corrected.
    """
    return _compute_return_max_long_score(
        row, earnings_reaction_fallback=True, use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS, allow_generic_material_events=True,
        use_entropy_bonus=True, use_contrarian_obv=True, use_contrarian_bb=True,
        use_contrarian_rsi=True, use_ou_bonus=True,
    )


def compute_return_max_long_score_v18(row: dict[str, Any]) -> float:
    """V18 = tier2+tier3 + full contrarian reversal.

    This is the first "grand unified" long scorer: predictable/trending/orderly
    states (entropy/hurst/OU/gravity/temperature) plus under-owned technical
    posture (OBV/BB/RSI contrarian) inside one additive score.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
        use_tier3_bonuses=True,
        use_contrarian_obv=True,
        use_contrarian_bb=True,
        use_contrarian_rsi=True,
    )


def compute_return_max_long_score_v18b(row: dict[str, Any]) -> float:
    """V18B = V18 + coherence scaling.

    Physics analogy: when trend/predictability/technical signals align,
    constructive interference should reorder near-threshold names.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
        use_tier3_bonuses=True,
        use_contrarian_obv=True,
        use_contrarian_bb=True,
        use_contrarian_rsi=True,
        use_coherence_scaling=True,
    )


def compute_return_max_long_score_v18c(row: dict[str, Any]) -> float:
    """V18C = V18 + chaos penalties.

    Penalizes hot/uncertain states multiplicatively, keeping the additive alpha
    stack from overtrusting technically crowded but noisy setups.
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
        use_tier3_bonuses=True,
        use_contrarian_obv=True,
        use_contrarian_bb=True,
        use_contrarian_rsi=True,
        use_temperature_gate=True,
        use_uncertainty_gate=True,
    )


def compute_return_max_long_score_v18d(row: dict[str, Any]) -> float:
    """V18D = V18B + escape-velocity scaling.

    Relativistic analogy: strong post-event impulse only persists when it can
    overcome uncertainty/temperature/gravitational drag. High reaction in a low-
    drag state gets boosted; weak reaction in a noisy state is scaled down.
    """
    raw = _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_tier2_bonuses=True,
        use_tier3_bonuses=True,
        use_contrarian_obv=True,
        use_contrarian_bb=True,
        use_contrarian_rsi=True,
        use_coherence_scaling=True,
    )

    reaction = _safe_float(row.get("reaction_day_return")) or 0.0
    entropy = _safe_float(row.get("pre_event_entropy_60d")) or 2.0
    temperature = _safe_float(row.get("pre_event_market_temperature")) or 1.0
    gravity = _safe_float(row.get("pre_event_gravitational_pull")) or 1.0
    hurst = _safe_float(row.get("pre_event_hurst_60d")) or 0.5
    obv = _safe_float(row.get("pre_event_obv_slope_20d")) or 0.0
    bb = _safe_float(row.get("pre_event_bb_position")) or 0.5
    rsi = _safe_float(row.get("pre_event_rsi_14")) or 50.0

    drag = (
        1.0
        + max(0.0, entropy - 1.85) * 0.70
        + max(0.0, temperature - 1.00) * 0.60
        + max(0.0, gravity - 1.00) * 0.25
    )

    contrarian_charge = 0.0
    if obv < 0.0:
        contrarian_charge += 0.25
    if bb < 0.50:
        contrarian_charge += 0.20
    if rsi < 50.0:
        contrarian_charge += 0.20
    if hurst > 0.55:
        contrarian_charge += 0.15

    escape_velocity = reaction * (1.0 + contrarian_charge) / drag
    if escape_velocity > 0.055:
        raw *= 1.08
    elif escape_velocity < 0.030:
        raw *= 0.92

    if entropy < 1.8 and temperature < 1.0 and hurst > 0.55:
        raw *= 1.04

    return _clamp(raw)


def compute_return_max_short_score_v1(row: dict[str, Any]) -> float:
    """Short-side PEAD scoring for bearish events.

    Academic basis: negative PEAD drift is well-documented (Ball & Brown 1968,
    Bernard & Thomas 1989). Stocks with strong negative reactions continue
    to decline as the market gradually incorporates bad news.

    Hard gates:
      - reaction_day_return < -0.05 (strong bearish reaction)
      - reaction_day_return > -0.25 (not catastrophic — those may bounce)
      - volume_ratio > 1.5 (institutional conviction)
      - parse_confidence >= 0.50

    Components:
      - Reaction magnitude (45%): bigger drop = stronger negative drift
      - Volume conviction (25%): high volume = institutional selling
      - Close location (15%): low close = selling into close = continued pressure
      - Document quality (15%): higher quality parse = more reliable signal

    Sets trade_direction = "short" as side effect.
    """
    event_type = str(row.get("event_type", "")).lower()
    if event_type not in {"earnings_release", "guidance_update", "material_contract",
                          "other_material_event", "unknown"}:
        return 0.0

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction > -0.03 or reaction < -0.25:
        return 0.0

    vol = _safe_float(row.get("volume_ratio_20d"))
    if vol is None or vol < 1.0:
        return 0.0

    parse_conf = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf is not None and parse_conf < 0.40:
        return 0.0

    # Set short direction
    row["trade_direction"] = "short"

    # 1. Reaction magnitude (45%) — bigger drop = stronger signal
    abs_ret = abs(reaction)
    reaction_score = min(1.0, (abs_ret - 0.05) / 0.10 * 0.6 + 0.5)

    # 2. Volume conviction (25%)
    vol_score = min(1.0, vol / 5.0)

    # 3. Close location (15%) — low close = selling pressure into close
    cl = _safe_float(row.get("close_location"))
    if cl is not None:
        cl_score = max(0.0, 1.0 - cl)  # invert: lower close = higher score
    else:
        cl_score = 0.5

    # 4. Document quality (15%)
    doc_quality = _normalized(row.get("document_quality_score"))
    sig_strength = _normalized(row.get("signal_strength_score"))
    doc_score = max(doc_quality, sig_strength)

    raw = (reaction_score * 0.45 + vol_score * 0.25
           + cl_score * 0.15 + doc_score * 0.15)
    return _clamp(raw)


def compute_return_max_long_score_ml_v1(row: dict[str, Any]) -> float:
    """ML-based scoring: logistic regression trained on 1,834 events.

    Features: reaction, close_location, volume, gap, doc quality, signal strength,
    parse confidence, guidance, oneoff, market cap, event type, direction, timing.

    Train AUC: 0.707, Valid AUC: 0.729. Q5 88.3% strong drift vs Q1 37.3%.
    Output is probability of strong drift (mfe_10d - mae_10d > 8%).
    """
    import math

    reaction = _safe_float(row.get("reaction_day_return"))
    if reaction is None or reaction < 0.02:
        return 0.0

    # Hard gates (same as hand-coded models)
    event_type = str(row.get("event_type", "")).lower()
    if event_type not in {"earnings_release", "guidance_update", "material_contract",
                          "other_material_event", "unknown"}:
        return 0.0

    parse_conf = _safe_float(row.get("parse_confidence_overall"))
    if parse_conf is not None and parse_conf < 0.50:
        return 0.0

    oneoff = _safe_float(row.get("oneoff_penalty"))
    if oneoff is not None and oneoff >= 0.40:
        return 0.0

    # Extract features
    cl = _safe_float(row.get("close_location")) or 0.0
    vol = _safe_float(row.get("volume_ratio_20d")) or 1.0
    gap = _safe_float(row.get("gap_size")) or 0.0
    doc = _safe_float(row.get("document_quality_score")) or 0.0
    sig = _safe_float(row.get("signal_strength_score")) or 0.0
    parse = parse_conf or 0.0
    guid = _safe_float(row.get("guidance_direction_score")) or 0.0
    oneoff_v = oneoff or 0.0
    mcap = _safe_float(row.get("market_cap_proxy")) or 0.0
    is_earn = 1.0 if event_type == "earnings_release" else 0.0
    is_bull = 1.0 if str(row.get("event_direction", "")).lower() == "bullish" else 0.0
    ftb = str(row.get("filing_time_bucket", "")).lower()
    is_sd = 1.0 if ftb in ("market_hours", "pre_market") else 0.0
    log_mcap = math.log1p(mcap)
    rxv = reaction * vol

    # Model parameters (trained on midlarge-liquid-long-v1_bucketfix_full_audit/train)
    means = [0.07647, 0.66423, 2.77802, 0.05295, 0.59386, 0.33206, 0.57791,
             0.47301, 0.49146, 133848323174.3, 0.56379, 0.28462, 0.08561, 24.38727, 0.29600]
    scales = [0.07192, 0.26650, 1.70445, 0.06393, 0.12190, 0.28695, 0.13448,
              0.36046, 0.36866, 423835964594.9, 0.49591, 0.45124, 0.27978, 1.35218, 0.64006]
    coefs = [1.01880, -0.23567, -0.33227, -0.07947, 0.05002, 0.13124, 0.04875,
             0.05731, -0.18163, 0.23227, -0.15206, -0.06086, -0.00290, -0.45036, 0.29914]
    intercept = 0.44324

    raw_feats = [reaction, cl, vol, gap, doc, sig, parse, guid, oneoff_v, mcap,
                 is_earn, is_bull, is_sd, log_mcap, rxv]

    # Standardize and compute logit
    z = intercept
    for i in range(len(raw_feats)):
        scaled = (raw_feats[i] - means[i]) / max(scales[i], 1e-10)
        z += scaled * coefs[i]

    prob = 1.0 / (1.0 + math.exp(-z))
    return _clamp(prob)


def compute_return_max_long_score_ml_v2(row: dict[str, Any]) -> float:
    """ML scoring v2: numeric logistic model trained on canonical v17-era features.

    Target:
      strong_drift_10d := (mfe_10d - mae_10d) > 8%

    Notes:
      - Uses only snapshot features already materialized in the canonical dataset.
      - This is a ranking/classification scorer, not a direct 14d-return regressor.
      - Categorical event fields were intentionally excluded because they added
        little incremental signal while making runtime inference heavier.
    """
    return _compute_numeric_logistic_score(
        row,
        features=_RETURN_MAX_LONG_ML_V2_FEATURES,
        medians=_RETURN_MAX_LONG_ML_V2_MEDIANS,
        means=_RETURN_MAX_LONG_ML_V2_MEANS,
        scales=_RETURN_MAX_LONG_ML_V2_SCALES,
        coefficients=_RETURN_MAX_LONG_ML_V2_COEFFICIENTS,
        intercept=_RETURN_MAX_LONG_ML_V2_INTERCEPT,
    )


def compute_return_max_long_score_ml_v2_h1(row: dict[str, Any]) -> float:
    """Hybrid v1: keep v13e eligibility, use ML for re-ranking.

    This is the least invasive hybrid:
      - v13e remains the hard eligibility gate
      - among already-eligible rows, ML probability becomes the rank score
    """
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    return compute_return_max_long_score_ml_v2(row)


def compute_return_max_long_score_ml_v2_h2(row: dict[str, Any]) -> float:
    """Hybrid v2: weighted blend of v13e and ML probability."""
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    ml_score = compute_return_max_long_score_ml_v2(row)
    return _clamp(base_score * 0.65 + ml_score * 0.35)


def compute_return_max_long_score_ml_v2_h3(row: dict[str, Any]) -> float:
    """Hybrid v3: v13e base plus stronger ML rank bonus."""
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    ml_score = compute_return_max_long_score_ml_v2(row)
    return _clamp(base_score + 0.30 * (ml_score - 0.50))


def compute_return_max_long_score_ml_v3(row: dict[str, Any]) -> float:
    """ML scoring v3: numeric logistic model with SUE + peer-relative features."""
    return _compute_numeric_logistic_score(
        row,
        features=_RETURN_MAX_LONG_ML_V3_FEATURES,
        medians=_RETURN_MAX_LONG_ML_V3_MEDIANS,
        means=_RETURN_MAX_LONG_ML_V3_MEANS,
        scales=_RETURN_MAX_LONG_ML_V3_SCALES,
        coefficients=_RETURN_MAX_LONG_ML_V3_COEFFICIENTS,
        intercept=_RETURN_MAX_LONG_ML_V3_INTERCEPT,
    )


def compute_return_max_long_score_ml_v3_h1(row: dict[str, Any]) -> float:
    """Hybrid v1: keep v13e eligibility, use ML v3 for re-ranking."""
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    return compute_return_max_long_score_ml_v3(row)


def compute_return_max_long_score_ml_v3_h2(row: dict[str, Any]) -> float:
    """Hybrid v2: weighted blend of v13e and ML v3 probability."""
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    ml_score = compute_return_max_long_score_ml_v3(row)
    return _clamp(base_score * 0.65 + ml_score * 0.35)


def compute_return_max_long_score_ml_v3_h3(row: dict[str, Any]) -> float:
    """Hybrid v3: v13e base plus stronger ML v3 rank bonus."""
    base_score = compute_return_max_long_score_v13e(row)
    if base_score <= 0.0:
        return 0.0
    ml_score = compute_return_max_long_score_ml_v3(row)
    return _clamp(base_score + 0.30 * (ml_score - 0.50))


def compute_return_max_longshort_v1(row: dict[str, Any]) -> float:
    """Combined long + short PEAD scoring.

    Applies long scoring (v13e) for bullish reactions and short scoring (v1)
    for bearish reactions. Returns the higher of the two, allowing both
    long and short candidates to pass through to engine matching.
    """
    long_score = compute_return_max_long_score_v13e(row)
    if long_score > 0:
        return long_score
    return compute_return_max_short_score_v1(row)


def compute_return_max_longshort_ml_v1(row: dict[str, Any]) -> float:
    """Combined ML long + short (v1) scoring."""
    long_score = compute_return_max_long_score_ml_v1(row)
    if long_score > 0:
        return long_score
    return compute_return_max_short_score_v1(row)


def compute_return_max_longshort_v1b(row: dict[str, Any]) -> float:
    """Combined long (v17b contrarian) + short (v1) scoring."""
    long_score = compute_return_max_long_score_v17b(row)
    if long_score > 0:
        return long_score
    return compute_return_max_short_score_v1(row)


def compute_return_max_long_score_v11_surprise(row: dict[str, Any]) -> float:
    """Legacy experimental variant: V5 + earnings surprise bonus.

    Uses actual vs estimated EPS surprise from Oracle earnings/surprise API.
    Empirical finding: small beats (0-3%) have 82.4% WR vs 54.8% for big beats.
    Moderate surprise = strongest PEAD drift (gradual repricing).
    """
    return _compute_return_max_long_score(
        row,
        earnings_reaction_fallback=True,
        use_signal_strength_proxy=True,
        weights=_RETURN_MAX_LONG_V2_WEIGHTS,
        allow_generic_material_events=True,
        use_earnings_surprise_bonus=True,
    )
