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