You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
401 lines
12 KiB
Python
401 lines
12 KiB
Python
"""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
|