"""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, 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_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, ) -> 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 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 # 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 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_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, )