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.

1198 lines
39 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__)
_RETURN_MAX_LONG_V2_WEIGHTS = {
"document_quality": 0.20,
"guidance_direction": 0.12,
"oneoff_inverse": 0.08,
"parse_overall": 0.05,
"parse_direction": 0.05,
"reaction": 0.15,
"close_location": 0.10,
"volume": 0.08,
"gap": 0.07,
}
_RETURN_MAX_LONG_V3_WEIGHTS = {
"document_quality": 0.14,
"guidance_direction": 0.08,
"oneoff_inverse": 0.07,
"parse_overall": 0.03,
"parse_direction": 0.03,
"reaction": 0.24,
"close_location": 0.16,
"volume": 0.10,
"gap": 0.05,
}
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))
def compute_return_max_long_score(row: dict[str, Any]) -> float:
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=False,
use_signal_strength_proxy=False,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
)
def compute_return_max_long_score_v2(row: dict[str, Any]) -> float:
"""V2 long-biased score with earnings-direction fallback.
For earnings releases, parser event_direction may be mixed/unknown even when
the market reaction is unambiguously positive. V2 allows those cases when
the reaction itself is positive, while still hard-rejecting explicitly
bearish parser outputs.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
)
def compute_return_max_long_score_v3(row: dict[str, Any]) -> float:
"""V3 long-biased score with a more market-confirmed ranking profile.
V3 keeps the same hard gates and earnings fallback as v2, but tilts the
ranking toward reaction/close/volume and away from parser-quality terms.
The goal is to preserve v2's eligibility semantics while changing only the
ordering inside the already-eligible cohort.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V3_WEIGHTS,
)
def compute_return_max_long_score_v4(row: dict[str, Any]) -> float:
"""Backward-compatible alias for v5."""
return compute_return_max_long_score_v5(row)
def compute_return_max_long_score_v5(row: dict[str, Any]) -> float:
"""V5 keeps v2 semantics and adds a conservative generic material-event branch."""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
)
def compute_return_max_long_score_v6(row: dict[str, Any]) -> float:
"""V6 replaces linear market scoring with empirical zone functions.
Zone functions capture non-linear optimal regions discovered in data:
- Reaction: peak at 8-20%, drops at 25%+ (mean reversion)
- Close location: peak at 80-90%, drops at 92%+ (exhaustion)
- Volume: peak at 3-5x, drops at 6x+ (overhyped)
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
use_zone_scoring=True,
)
def compute_return_max_long_score_v7(row: dict[str, Any]) -> float:
"""V7 = V5 + market-confirmed parse gate relaxation.
For earnings with strong market confirmation (ret>5%, close>0.55, vol>1.3),
the parse_confidence gate is lowered from 0.60 to 0.45. This unlocks
events where the parser was uncertain but the market clearly validated.
Only affects events currently at score=0 due to parse<0.60.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
use_market_confirmed_gate=True,
)
def compute_return_max_long_score_v8(row: dict[str, Any]) -> float:
"""V8 = V5 + conditional financial feature bonus.
For earnings_release events with eps_growth_qoq/revenue_growth_qoq data,
adds a conservative bonus (up to 7%) to the score. Only applied when
financial data is present (not null). Designed for snapshots that include
financial_v1 features.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
use_financial_bonus=True,
)
def _compute_generic_material_event_score(row: dict[str, Any]) -> float:
"""Score orderly after-close material events without using earnings-specific logic."""
parse_conf_overall = _safe_float(row.get("parse_confidence_overall"))
if parse_conf_overall is None or parse_conf_overall < 0.45:
return 0.0
oneoff_penalty = _safe_float(row.get("oneoff_penalty"))
if oneoff_penalty is None or oneoff_penalty >= 0.40:
return 0.0
reaction_day_return = _safe_float(row.get("reaction_day_return"))
if reaction_day_return is None or reaction_day_return <= -0.05:
return 0.0
document_quality = max(
_normalized(row.get("document_quality_score")),
_normalized(row.get("signal_strength_score")),
)
document_component = (
document_quality * 0.18
+ _normalized(parse_conf_overall) * 0.12
+ (1.0 - _normalized(oneoff_penalty)) * 0.10
)
market_component = (
_orderly_material_close_score(row.get("close_location")) * 0.22
+ _normalize_linear(reaction_day_return, -0.02, 0.08) * 0.18
+ _orderly_material_volume_score(row.get("volume_ratio_20d")) * 0.10
+ _orderly_material_gap_score(row) * 0.10
)
return _clamp(document_component + market_component - _overheat_penalty(row))
def _orderly_material_close_score(raw: Any) -> float:
value = _safe_float(raw)
if value is None:
return 0.0
if value <= 0.45:
return 0.0
if value <= 0.70:
return _normalize_linear(value, 0.45, 0.70)
if value <= 0.85:
return 1.0 - 0.30 * _normalize_linear(value, 0.70, 0.85)
if value >= 0.95:
return 0.20
return 0.70 - 0.50 * _normalize_linear(value, 0.85, 0.95)
def _orderly_material_volume_score(raw: Any) -> float:
value = _safe_float(raw)
if value is None:
return 0.5
if value <= 0.80:
return 0.20
if value <= 1.20:
return 0.20 + 0.80 * _normalize_linear(value, 0.80, 1.20)
if value <= 2.00:
return 1.0 - 0.40 * _normalize_linear(value, 1.20, 2.00)
if value <= 3.00:
return 0.60 - 0.40 * _normalize_linear(value, 2.00, 3.00)
return 0.20
def _orderly_material_gap_score(row: dict[str, Any]) -> float:
gap = _safe_float(row.get("gap_size"))
if gap is None:
return 0.5
if -0.02 <= gap <= 0.02:
return 1.0
if -0.04 <= gap < -0.02:
return 0.45
if 0.02 < gap <= 0.05:
return 0.50
return 0.0
def compute_return_max_long_score_v10(row: dict[str, Any]) -> float:
"""V10 = V5 + macro regime bonus from VIX and HY spread.
High VIX (>18) + wide HY spread (>3.25) = favorable PEAD regime.
Empirical: 62.3% WR vs 50.8% in neutral regime (+11.5pp, n=2648).
Adds up to +12% bonus in favorable regime, -5% penalty in adverse regime.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
use_macro_regime_bonus=True,
)
def compute_return_max_long_score_v9g(row: dict[str, Any]) -> float:
"""V9G = V9 + hard gate: reject events with negative prior same-ticker drift.
When prior_event_fwd5d < 0 (and is available), returns 0.0.
Null prior (first event for ticker) passes through.
"""
prior = _safe_float(row.get("prior_event_fwd5d"))
if prior is not None and prior < 0.0:
return 0.0
return compute_return_max_long_score_v9(row)
def compute_return_max_long_score_v9(row: dict[str, Any]) -> float:
"""V9 = V5 + cross-event momentum bonus from prior same-ticker drift.
When the same ticker's prior event had positive 5d drift (> +2%),
adds a bonus (up to 10%) reflecting PEAD persistence. When prior
drift was negative (< -2%), applies a penalty. Neutral when no
prior event or prior drift near zero.
"""
return _compute_return_max_long_score(
row,
earnings_reaction_fallback=True,
use_signal_strength_proxy=True,
weights=_RETURN_MAX_LONG_V2_WEIGHTS,
allow_generic_material_events=True,
use_prior_drift_momentum=True,
)
def _compute_return_max_long_score(
row: dict[str, Any],
*,
earnings_reaction_fallback: bool,
use_signal_strength_proxy: bool,
weights: dict[str, float],
allow_generic_material_events: bool = False,
use_zone_scoring: bool = False,
use_market_confirmed_gate: bool = False,
use_financial_bonus: bool = False,
use_prior_drift_momentum: bool = False,
use_macro_regime_bonus: bool = False,
) -> float:
"""Long-biased score for return-max event strategies.
Hard gates are applied before the score is computed. Scores are in [0, 1].
Missing wiki/attention data is treated as neutral (no overheat penalty).
"""
event_type = str(row.get("event_type", "")).lower()
if event_type == "management_change":
return _compute_management_change_score(row)
if allow_generic_material_events and event_type in {"material_contract", "other_material_event"}:
return _compute_generic_material_event_score(row)
if event_type not in {"earnings_release", "guidance_update"}:
return 0.0
event_direction = str(row.get("event_direction", "unknown")).lower()
reaction_day_return = _safe_float(row.get("reaction_day_return"))
if event_type == "earnings_release" and earnings_reaction_fallback:
if event_direction == "bearish":
return 0.0
if event_direction != "bullish" and (reaction_day_return is None or reaction_day_return <= 0.0):
return 0.0
elif event_direction != "bullish":
return 0.0
parse_conf_overall = _safe_float(row.get("parse_confidence_overall"))
parse_gate = 0.60
if (
use_market_confirmed_gate
and event_type == "earnings_release"
and reaction_day_return is not None and reaction_day_return > 0.05
):
cl = _safe_float(row.get("close_location"))
vol = _safe_float(row.get("volume_ratio_20d"))
if cl is not None and cl > 0.55 and vol is not None and vol > 1.3:
parse_gate = 0.45
if parse_conf_overall is None or parse_conf_overall < parse_gate:
return 0.0
oneoff_penalty = _safe_float(row.get("oneoff_penalty"))
if oneoff_penalty is None or oneoff_penalty >= 0.40:
return 0.0
parse_conf_direction = _safe_float(row.get("parse_confidence_event_direction"))
if parse_conf_direction is None:
parse_conf_direction = parse_conf_overall
guidance_conf = _safe_float(row.get("parse_confidence_guidance"))
if guidance_conf is None:
guidance_conf = parse_conf_overall
if event_type == "guidance_update":
guidance_status = str(row.get("guidance_status", "")).lower()
if guidance_status != "raised" or guidance_conf < 0.70:
return 0.0
document_quality = _normalized(row.get("document_quality_score"))
if use_signal_strength_proxy:
document_quality = max(document_quality, _normalized(row.get("signal_strength_score")))
document_component = (
document_quality * weights["document_quality"]
+ _normalized(row.get("guidance_direction_score")) * weights["guidance_direction"]
+ (1.0 - _normalized(row.get("oneoff_penalty"))) * weights["oneoff_inverse"]
+ _normalized(parse_conf_overall) * weights["parse_overall"]
+ _normalized(parse_conf_direction) * weights["parse_direction"]
)
if use_zone_scoring:
market_component = (
_zone_reaction_score(row.get("reaction_day_return")) * weights["reaction"]
+ _zone_close_location_score(row.get("close_location")) * weights["close_location"]
+ _zone_volume_score(row.get("volume_ratio_20d")) * weights["volume"]
+ _gap_quality_score(row) * weights["gap"]
)
conviction = _safe_float(row.get("institutional_conviction_score"))
if conviction is not None:
market_component += conviction * 0.03
else:
market_component = (
_normalize_linear(row.get("reaction_day_return"), 0.03, 0.12) * weights["reaction"]
+ _normalize_linear(row.get("close_location"), 0.60, 0.90) * weights["close_location"]
+ _normalize_linear(row.get("volume_ratio_20d"), 1.0, 2.5) * weights["volume"]
+ _gap_quality_score(row) * weights["gap"]
)
positive_weight_sum = sum(weights.values())
if positive_weight_sum <= 0.0:
return 0.0
raw = (document_component + market_component) / positive_weight_sum - _overheat_penalty(row)
if use_financial_bonus:
raw += _financial_bonus_score(row) * 0.07
if use_prior_drift_momentum:
raw += _prior_drift_momentum_score(row) * 0.10
if use_macro_regime_bonus:
raw += _macro_regime_score(row) * 0.12
return _clamp(raw)
def _compute_management_change_score(row: dict[str, Any]) -> float:
"""Score management_change events for long-side PEAD.
Empirically validated on 343 train events (2022-2025):
- Bullish reaction MC: 65.2% win rate at 5d, +0.23% mean
- close_location >= 0.5 is the strongest single filter (69.7% WR)
- Volume filtering hurts (opposite of earnings) — minimal weight
Hard gates:
- reaction_day_return > 0 (bullish market reaction only)
- parse_confidence_overall >= 0.50
- oneoff_penalty < 0.40
Components:
Document (35%): document_quality (20%), parse_confidence (10%),
risk penalty (5%)
Market (65%): close_location (30%), reaction_magnitude (20%),
gap_quality (10%), volume (5%)
"""
reaction = _safe_float(row.get("reaction_day_return"))
if reaction is None or reaction <= 0.0:
return 0.0
parse_conf = _safe_float(row.get("parse_confidence_overall"))
if parse_conf is None or parse_conf < 0.50:
return 0.0
oneoff = _safe_float(row.get("oneoff_penalty"))
if oneoff is None or oneoff >= 0.40:
return 0.0
# --- Document component (35%) ---
document_component = (
_normalized(row.get("document_quality_score")) * 0.20
+ _normalized(parse_conf) * 0.10
+ (1.0 - _normalized(oneoff)) * 0.05
)
# --- Market component (65%) ---
# close_location is the strongest MC signal (69.7% WR when >= 0.5)
# Reaction: 0-2% is the sweet spot; larger reactions have poor follow-through
# Volume: minimal weight (filtering hurts MC unlike earnings)
market_component = (
_normalize_linear(row.get("close_location"), 0.40, 0.80) * 0.30
+ _normalize_linear(reaction, 0.0, 0.05) * 0.20
+ _gap_quality_score(row) * 0.10
+ _normalize_linear(row.get("volume_ratio_20d"), 0.8, 2.0) * 0.05
)
raw = document_component + market_component
return _clamp(raw)
def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
return max(low, min(high, value))
def _normalize_linear(raw: Any, low: float, high: float) -> float:
value = _safe_float(raw)
if value is None:
return 0.0
if high <= low:
return 0.0
return _clamp((value - low) / (high - low))
def _normalized(raw: Any) -> float:
value = _safe_float(raw)
if value is None:
return 0.0
return _clamp(value)
def _gap_quality_score(row: dict[str, Any]) -> float:
gap = _safe_float(row.get("gap_size"))
if gap is None:
return 0.5
if gap < 0.0:
return 0.0
if gap <= 0.02:
return 1.0
if gap >= 0.05:
return 0.0
return _clamp(1.0 - (gap - 0.02) / 0.03)
def _zone_reaction_score(raw: Any) -> float:
"""Non-linear reaction scoring: peak at 8-20%, drops at 25%+.
Empirical zones from data analysis:
- 0-3%: weak signal, ramp 0.2 -> 0.5
- 3-8%: moderate, ramp 0.5 -> 0.85
- 8-20%: optimal zone, plateau 0.85 -> 1.0
- 20-25%: cooling, drop 1.0 -> 0.40
- 25%+: mean-reversion risk, floor 0.15
"""
value = _safe_float(raw)
if value is None:
return 0.0
r = abs(value)
if r < 0.03:
return 0.20 + (r / 0.03) * 0.30
if r < 0.08:
return 0.50 + ((r - 0.03) / 0.05) * 0.35
if r <= 0.20:
return 0.85 + ((r - 0.08) / 0.12) * 0.15
if r <= 0.25:
return 1.0 - ((r - 0.20) / 0.05) * 0.60
return 0.15
def _zone_close_location_score(raw: Any) -> float:
"""Non-linear close location scoring: peak at 80-90%, exhaustion at 92%+.
Empirical zones:
- 0-60%: weak, ramp 0.10 -> 0.40
- 60-70%: moderate, dip zone 0.40 -> 0.55
- 70-80%: building, ramp 0.55 -> 0.85
- 80-90%: optimal zone, plateau 0.85 -> 1.0
- 90-92%: transition 1.0 -> 0.55
- 92%+: exhaustion, floor 0.20
"""
value = _safe_float(raw)
if value is None:
return 0.0
if value < 0.60:
return 0.10 + (value / 0.60) * 0.30
if value < 0.70:
return 0.40 + ((value - 0.60) / 0.10) * 0.15
if value < 0.80:
return 0.55 + ((value - 0.70) / 0.10) * 0.30
if value <= 0.90:
return 0.85 + ((value - 0.80) / 0.10) * 0.15
if value <= 0.92:
return 1.0 - ((value - 0.90) / 0.02) * 0.45
return 0.20
def _zone_volume_score(raw: Any) -> float:
"""Non-linear volume scoring: peak at 3-5x, drops at 6x+.
Empirical zones:
- 0-1x: below average, floor 0.15
- 1-2x: normal conviction, ramp 0.15 -> 0.55
- 2-3x: building, ramp 0.55 -> 0.85
- 3-5x: optimal institutional zone, plateau 0.85 -> 1.0
- 5-6x: cooling, drop 1.0 -> 0.35
- 6x+: overhyped, floor 0.10
"""
value = _safe_float(raw)
if value is None:
return 0.0
if value < 1.0:
return 0.15
if value < 2.0:
return 0.15 + ((value - 1.0) / 1.0) * 0.40
if value < 3.0:
return 0.55 + ((value - 2.0) / 1.0) * 0.30
if value <= 5.0:
return 0.85 + ((value - 3.0) / 2.0) * 0.15
if value <= 6.0:
return 1.0 - ((value - 5.0) / 1.0) * 0.65
return 0.10
def _exhaustion_penalty(row: dict[str, Any]) -> float:
"""Penalty for empirically identified exhaustion zones.
Applied on top of linear scoring to penalize entries in zones
where follow-through is poor, without changing the core scoring.
Zones (from data analysis):
- Close >= 93%: exhaustion buying (-0.15% mean OOS)
- Reaction >= 22%: mean-reversion risk
- Volume >= 6x: overhyped crowd behavior (46% WR)
"""
penalty = 0.0
cl = _safe_float(row.get("close_location"))
if cl is not None:
if cl >= 0.92:
penalty += _normalize_linear(cl, 0.92, 1.0) * 0.05
elif cl < 0.60:
penalty += (1.0 - _normalize_linear(cl, 0.40, 0.60)) * 0.02
reaction = _safe_float(row.get("reaction_day_return"))
if reaction is not None and abs(reaction) >= 0.20:
penalty += _normalize_linear(abs(reaction), 0.20, 0.30) * 0.04
vol = _safe_float(row.get("volume_ratio_20d"))
if vol is not None and vol >= 5.0:
penalty += _normalize_linear(vol, 5.0, 8.0) * 0.03
return penalty
def _macro_regime_score(row: dict[str, Any]) -> float:
"""Score from macro regime: VIX level and HY credit spread.
Empirical finding on 2,648 earnings events:
- VIX > 18 AND HY > 3.25: 62.3% WR, +1.61% mean (favorable)
- Otherwise: 50.8% WR, +0.09% mean (neutral/adverse)
Returns [-0.5, +1.0]; caller scales by weight (e.g. 0.12 = 12%).
"""
vix = _safe_float(row.get("macro_vix"))
hy = _safe_float(row.get("macro_hy_spread"))
if vix is None:
return 0.0
# Favorable: high VIX + wide HY = fear regime, PEAD strongest
if vix > 18 and hy is not None and hy > 3.25:
return min(1.0, (vix - 18) / 10) # 0.0 at VIX=18, 1.0 at VIX=28
# Adverse: mid VIX (15-18) = complacent, PEAD weakest
if 15 < vix <= 18:
return -0.5
return 0.0
def _prior_drift_momentum_score(row: dict[str, Any]) -> float:
"""Score from prior same-ticker event's 5d forward return (cross-event momentum).
Empirical finding: prior positive drift (>+2%) predicts 56.8% WR on next event
vs 48.7% WR when prior drift was negative (<-2%). 8.1pp spread, 0.87% mean
return spread across 10,729 events.
Returns [-1.0, +1.0]; caller scales by desired weight (e.g. 0.10 = 10%).
Positive when prior drift was positive, negative when prior drift was negative.
"""
prior = _safe_float(row.get("prior_event_fwd5d"))
if prior is None:
return 0.0
if prior > 0.02:
return min(1.0, prior / 0.06)
elif prior < -0.02:
return max(-1.0, prior / 0.06)
else:
return 0.0
def _financial_bonus_score(row: dict[str, Any]) -> float:
"""Conditional bonus from financial features (eps_growth_qoq, revenue_growth_qoq).
Only active for earnings_release events with non-null financial data.
Returns 0.0-1.0; caller scales by desired weight (e.g. 0.07 = 7%).
"""
event_type = str(row.get("event_type", "")).lower()
if event_type != "earnings_release":
return 0.0
eps = _safe_float(row.get("eps_growth_qoq"))
rev = _safe_float(row.get("revenue_growth_qoq"))
if eps is None and rev is None:
return 0.0
bonus = 0.0
if eps is not None and eps > 0:
bonus += min(1.0, eps / 0.20) * 0.6
if rev is not None and rev > 0:
bonus += min(1.0, rev / 0.15) * 0.4
return min(1.0, bonus)
def _overheat_penalty(row: dict[str, Any]) -> float:
penalties: list[float] = []
wiki_spike = _safe_float(row.get("attention_wiki_spike_10d"))
if wiki_spike is not None and wiki_spike > 1.0:
penalties.append(_normalize_linear(wiki_spike, 1.5, 4.0))
gap = _safe_float(row.get("gap_size"))
if gap is not None and gap > 0.02:
penalties.append(_normalize_linear(gap, 0.02, 0.08))
reaction = _safe_float(row.get("reaction_day_return"))
if reaction is not None and reaction > 0.12:
penalties.append(_normalize_linear(reaction, 0.12, 0.20))
if not penalties:
return 0.0
return statistics_mean(penalties) * 0.10
def statistics_mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def _safe_float(raw: Any) -> float | None:
try:
return float(raw)
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# 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 compute_patient_drift_score(row: dict[str, Any]) -> float:
"""Market-only score for patient drift execution model.
Designed from MFE/MAE empirical analysis: NLP features have zero
forward-return predictive power, so only market confirmation signals
are used. Paired with a patient execution model (fixed 4% stop,
no target, trailing + time exit, no early failure, no partials).
Hard gates (all must pass):
- event_type in {earnings_release, guidance_update}
- reaction_day_return > 0
- parse_confidence_overall >= 0.50
- oneoff_penalty < 0.40
Weighted components (100% market):
- Reaction magnitude (40%)
- Close location (35%)
- Volume conviction (15%)
- Gap alignment (10%)
Returns float in [0.0, 1.0]. Returns 0.0 if any gate fails.
"""
event_type = str(row.get("event_type", "")).lower()
if event_type not in {"earnings_release", "guidance_update"}:
return 0.0
reaction = _safe_float(row.get("reaction_day_return"))
if reaction is None or reaction <= 0.0:
return 0.0
parse_conf = _safe_float(row.get("parse_confidence_overall"))
if parse_conf is None or parse_conf < 0.50:
return 0.0
oneoff = _safe_float(row.get("oneoff_penalty"))
if oneoff is None or oneoff >= 0.40:
return 0.0
# --- Component 1: Reaction magnitude (40%) ---
# Linear: 0% -> 0.0, 3% -> 0.5, 8% -> 0.85, 15%+ -> 1.0
reaction_component = _normalize_linear(reaction, 0.0, 0.15)
# --- Component 2: Close location (35%) ---
# Linear: 0.40 -> 0.0, 0.90 -> 1.0
close_component = _normalize_linear(row.get("close_location"), 0.40, 0.90)
# --- Component 3: Volume conviction (15%) ---
# Linear: 1.0 -> 0.0, 2.5 -> 1.0; below 1.0 gets 0
volume_component = _normalize_linear(row.get("volume_ratio_20d"), 1.0, 2.5)
# --- Component 4: Gap alignment (10%) ---
gap_component = _gap_quality_score(row)
raw = (
reaction_component * 0.40
+ close_component * 0.35
+ volume_component * 0.15
+ gap_component * 0.10
)
return _clamp(raw)
def compute_microstructure_score(row: dict[str, Any]) -> float:
"""Event-agnostic score based on pure price microstructure signals.
Hypothesis: when the market reacts "quietly but decisively" to any
catalyst (positive return, close near high, below-average volume),
information asymmetry exists and subsequent drift follows.
No event_type gate — all events are eligible.
Hard gates:
- reaction_day_return must exist and > 0
Weighted components:
- Close location (35%) — close near high = decisive buying
- Reaction magnitude (30%) — positive confirms direction
- Volume quietness (20%) — below-average volume = informed, not crowd
- Gap alignment (15%) — small positive gap = orderly
Returns float in [0.0, 1.0]. Returns 0.0 if gate fails.
"""
reaction = _safe_float(row.get("reaction_day_return"))
if reaction is None or reaction <= 0.0:
return 0.0
# --- Component 1: Close location (35%) ---
# Higher close = more decisive buying
close_component = _normalize_linear(row.get("close_location"), 0.50, 0.95)
# --- Component 2: Reaction magnitude (30%) ---
# Moderate reactions (2-8%) are ideal; very large reactions may revert
reaction_component = _normalize_linear(reaction, 0.0, 0.10)
# --- Component 3: Volume quietness (20%) ---
# INVERSE: below-average volume scores HIGH (quiet conviction)
# vol < 0.5 -> 1.0, vol = 1.0 -> 0.5, vol > 2.0 -> 0.0
vol = _safe_float(row.get("volume_ratio_20d"))
if vol is None:
volume_component = 0.5
else:
volume_component = _clamp(1.0 - _normalize_linear(vol, 0.5, 2.0))
# --- Component 4: Gap alignment (15%) ---
gap = _safe_float(row.get("gap_size"))
if gap is None:
gap_component = 0.5
elif gap < 0:
gap_component = 0.1 # negative gap contradicts positive reaction
elif gap <= 0.02:
gap_component = 1.0 # small positive gap = orderly
elif gap <= 0.05:
gap_component = 0.6 # moderate gap
else:
gap_component = 0.2 # large gap = exhaustion risk
raw = (
close_component * 0.35
+ reaction_component * 0.30
+ volume_component * 0.20
+ gap_component * 0.15
)
return _clamp(raw)
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