"""Rule-based entry score model for the backtester. Computes a composite score in [0, 1] from event, market, and text features available at entry time (no forward-looking data). Higher score = more favorable entry conditions for a long swing trade. Event/Document Quality (55% weight — primary signal): - Event quality (20%) — parser confidence + signal strength - Earnings surprise (12%) — SUE/EPS growth (earnings events only) - Risk penalty (10%) — oneoff risk flags reduce score - Parse confidence (8%) — parse_confidence_overall from parser - Direction clarity (5%) — event_direction categorical field Market Confirmation (35% weight — secondary signal): - Reaction quality (12%) — moderate positive return is ideal - Close strength (10%) — close near high = buyers won the day - Volume conviction (8%) — above-average but not exhaustion - Gap quality (5%) — small positive gap = orderly strength Text (10% weight — filing sentiment): - LM sentiment (10%) — Loughran-McDonald filing tone """ 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 event + market + text features. Components and weights: Event/Document Quality (55%): 1. Event quality (20%) — parser confidence + signal strength 2. Earnings surprise (12%) — SUE/EPS growth (earnings events only) 3. Risk penalty (10%) — oneoff risk flags reduce score 4. Parse confidence (8%) — parse_confidence_overall from parser 5. Direction clarity (5%) — event_direction categorical field Market Confirmation (35%): 6. Reaction quality (12%) — moderate positive return is ideal 7. Close strength (10%) — close near high = buyers won the day 8. Volume conviction (8%) — above-average but not exhaustion 9. Gap quality (5%) — small positive gap = orderly strength Text (10%): 10. LM sentiment (10%) — Loughran-McDonald filing tone Returns float in [0.0, 1.0]. """ # Event/Document Quality components event = _event_quality_score(row) sue = _earnings_surprise_score(row) risk = _risk_penalty_score(row) parse_conf = _parse_confidence_score(row) direction = _direction_clarity_score(row) # Market Confirmation components reaction = _reaction_score(row) close = _close_strength_score(row) volume = _volume_score(row) gap = _gap_score(row) # Text sentiment component text = _text_sentiment_score(row) raw = ( # Event/Document Quality (55%) event * 0.20 + sue * 0.12 + risk * 0.10 + parse_conf * 0.08 + direction * 0.05 # Market Confirmation (35%) + reaction * 0.12 + close * 0.10 + volume * 0.08 + gap * 0.05 # Text (10%) + text * 0.10 ) 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. Sweet spot: moderate positive return (0.5-3%) suggests post-event continuation without being "already priced in". Mapping: +0.5% to +3% -> 0.9 (ideal PEAD zone) +0% to +0.5% -> 0.65 (flat, uncertain direction) +3% to +8% -> 0.45 (getting priced in) > +8% -> 0.2 (extreme — mean reversion risk) -2% to 0% -> 0.4 (mild negative) -5% to -2% -> 0.25 (moderate negative) < -5% -> 0.1 (strongly bearish) """ rdr = row.get("reaction_day_return") if rdr is None: return 0.5 r = float(rdr) if 0.005 <= r <= 0.03: return 0.9 elif 0.0 <= r < 0.005: return 0.65 elif 0.03 < r <= 0.08: return 0.45 elif r > 0.08: return 0.2 elif -0.02 <= r < 0.0: return 0.4 elif -0.05 <= r < -0.02: return 0.25 else: # r < -0.05 return 0.1 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