|
|
|
|
@ -1,18 +1,19 @@
|
|
|
|
|
"""Rule-based entry score model for the backtester.
|
|
|
|
|
|
|
|
|
|
Computes a composite score in [0, 1] from market features available
|
|
|
|
|
at event time (no forward-looking data). Higher score = more favorable
|
|
|
|
|
entry conditions for a long swing trade.
|
|
|
|
|
Computes a composite score in [0, 1] from market and event features
|
|
|
|
|
available at entry time (no forward-looking data). Higher score = more
|
|
|
|
|
favorable entry conditions for a long swing trade.
|
|
|
|
|
|
|
|
|
|
Based on Post-Earnings Announcement Drift (PEAD) microstructure:
|
|
|
|
|
- Moderate positive reaction → likely continuation
|
|
|
|
|
Market features (75% weight — primary signal):
|
|
|
|
|
- Moderate positive reaction → likely PEAD continuation
|
|
|
|
|
- Extreme positive reaction → already priced in, mean-reversion risk
|
|
|
|
|
- Close near session high → buyers in control
|
|
|
|
|
- Above-average volume → conviction (but extreme volume can signal exhaustion)
|
|
|
|
|
- Above-average volume → conviction (but extreme volume = exhaustion)
|
|
|
|
|
- Small positive gap → orderly strength
|
|
|
|
|
|
|
|
|
|
Design note: With limited data (14 records), this model uses general
|
|
|
|
|
market microstructure principles rather than fitted parameters. It can
|
|
|
|
|
be upgraded to ML when more training data is available.
|
|
|
|
|
Event features (25% weight — supplementary signal):
|
|
|
|
|
- Document quality & signal strength → confidence in the event parsing
|
|
|
|
|
- Risk flags (oneoff_penalty) → penalty for suspicious events
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
@ -24,44 +25,60 @@ logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_entry_score(row: dict[str, Any]) -> float:
|
|
|
|
|
"""Compute composite entry score from market features.
|
|
|
|
|
"""Compute composite entry score from market + event features.
|
|
|
|
|
|
|
|
|
|
Components and weights:
|
|
|
|
|
1. Reaction quality (35%) — moderate positive return is ideal
|
|
|
|
|
2. Close strength (30%) — close near high = buyers won the day
|
|
|
|
|
3. Volume conviction (20%) — above-average but not exhaustion
|
|
|
|
|
4. Gap quality (15%) — small positive gap = orderly strength
|
|
|
|
|
Market (75%):
|
|
|
|
|
1. Reaction quality (25%) — moderate positive return is ideal
|
|
|
|
|
2. Close strength (25%) — close near high = buyers won the day
|
|
|
|
|
3. Volume conviction (15%) — above-average but not exhaustion
|
|
|
|
|
4. Gap quality (10%) — small positive gap = orderly strength
|
|
|
|
|
Event (25%):
|
|
|
|
|
5. Event quality (15%) — parser confidence + signal strength
|
|
|
|
|
6. Risk penalty (10%) — oneoff risk flags reduce score
|
|
|
|
|
|
|
|
|
|
Returns float in [0.0, 1.0].
|
|
|
|
|
"""
|
|
|
|
|
# Market components
|
|
|
|
|
reaction = _reaction_score(row)
|
|
|
|
|
close = _close_strength_score(row)
|
|
|
|
|
volume = _volume_score(row)
|
|
|
|
|
gap = _gap_score(row)
|
|
|
|
|
|
|
|
|
|
raw = reaction * 0.35 + close * 0.30 + volume * 0.20 + gap * 0.15
|
|
|
|
|
# Event components (gracefully handle missing features)
|
|
|
|
|
event = _event_quality_score(row)
|
|
|
|
|
risk = _risk_penalty_score(row)
|
|
|
|
|
|
|
|
|
|
raw = (
|
|
|
|
|
reaction * 0.25
|
|
|
|
|
+ close * 0.25
|
|
|
|
|
+ volume * 0.15
|
|
|
|
|
+ gap * 0.10
|
|
|
|
|
+ event * 0.15
|
|
|
|
|
+ risk * 0.10
|
|
|
|
|
)
|
|
|
|
|
return max(0.0, min(1.0, raw))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Component scoring functions
|
|
|
|
|
# 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
|
|
|
|
|
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)
|
|
|
|
|
+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:
|
|
|
|
|
@ -87,7 +104,7 @@ def _reaction_score(row: dict[str, Any]) -> float:
|
|
|
|
|
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.
|
|
|
|
|
Linear mapping: 0.0 -> 0.1, 1.0 -> 1.0.
|
|
|
|
|
Close near session high = buyers controlled the day.
|
|
|
|
|
"""
|
|
|
|
|
cl = row.get("close_location")
|
|
|
|
|
@ -102,14 +119,14 @@ def _volume_score(row: dict[str, Any]) -> float:
|
|
|
|
|
"""Score based on volume_ratio_20d.
|
|
|
|
|
|
|
|
|
|
Above-average volume confirms conviction, but extreme volume
|
|
|
|
|
(>3×) can signal exhaustion or panic, so it gets discounted.
|
|
|
|
|
(>3x) can signal exhaustion or panic, so it gets discounted.
|
|
|
|
|
|
|
|
|
|
Mapping:
|
|
|
|
|
1.2×–2.0× → 0.8 (healthy conviction)
|
|
|
|
|
1.0×–1.2× → 0.6 (normal)
|
|
|
|
|
2.0×–3.0× → 0.55 (high — possible exhaustion)
|
|
|
|
|
> 3.0× → 0.4 (extreme — likely exhaustion)
|
|
|
|
|
< 1.0× → 0.3 (below average — no conviction)
|
|
|
|
|
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:
|
|
|
|
|
@ -131,17 +148,17 @@ def _volume_score(row: dict[str, Any]) -> float:
|
|
|
|
|
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.
|
|
|
|
|
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)
|
|
|
|
|
+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:
|
|
|
|
|
@ -160,3 +177,67 @@ def _gap_score(row: dict[str, Any]) -> float:
|
|
|
|
|
return 0.4
|
|
|
|
|
else: # g < -0.02
|
|
|
|
|
return 0.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|