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.

347 lines
10 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.
Alpha features (5 components, 100% weight):
Event Quality (35%):
- Event quality (35%) — parser confidence + signal strength + guidance
Market Confirmation (65%):
- Reaction quality (25%) — moderate positive return is ideal
- Close strength (18%) — close near high = buyers won the day
- Volume conviction (14%) — above-average but not exhaustion
- Gap quality (8%) — small positive gap = orderly strength
Non-alpha features (removed from composite, retained for analysis scripts):
- 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 alpha-only features.
Components and weights:
Event Quality (35%):
1. Event quality (35%) — parser confidence + signal strength
Market Confirmation (65%):
2. Reaction quality (25%) — moderate positive return is ideal
3. Close strength (18%) — close near high = buyers won the day
4. Volume conviction (14%) — above-average but not exhaustion
5. Gap quality (8%) — small positive gap = orderly strength
Returns float in [0.0, 1.0].
"""
# Alpha features only
event = _event_quality_score(row)
reaction = _reaction_score(row)
close = _close_strength_score(row)
volume = _volume_score(row)
gap = _gap_score(row)
raw = (
# Event Quality (35%)
event * 0.35
# Market Confirmation (65%)
+ reaction * 0.25
+ close * 0.18
+ volume * 0.14
+ gap * 0.08
)
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