"""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,
}


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,
        use_macro_regime_bonus=True,
    )


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,
        use_prior_drift_momentum=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,
    use_prior_drift_momentum: bool = False,
    use_macro_regime_bonus: 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"}:
        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 use_prior_drift_momentum:
        raw += _prior_drift_momentum_score(row) * 0.10

    if use_macro_regime_bonus:
        raw += _macro_regime_score(row) * 0.12

    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 _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
