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.
1668 lines
57 KiB
Python
1668 lines
57 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,
|
|
macro_regime_weight=0.12,
|
|
)
|
|
|
|
|
|
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,
|
|
prior_drift_weight=0.10,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v11(row: dict[str, Any]) -> float:
|
|
"""V11 = V5 + positive-only micro bonuses from macro regime and prior drift.
|
|
|
|
This keeps V5 eligibility intact and only nudges ordering for already-strong
|
|
candidates. Unlike v9/v10, adverse macro or negative prior drift do not
|
|
penalize the score.
|
|
"""
|
|
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,
|
|
prior_drift_weight=0.02,
|
|
macro_regime_weight=0.03,
|
|
positive_only_aux_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v11g(row: dict[str, Any]) -> float:
|
|
"""V11G = gentler V11, intended as a near-tiebreak perturbation."""
|
|
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,
|
|
prior_drift_weight=0.01,
|
|
macro_regime_weight=0.02,
|
|
positive_only_aux_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15(row: dict[str, Any]) -> float:
|
|
"""V15 = V5 + CONTRARIAN bonuses: low OBV + low BB + low OU = PEAD friendly.
|
|
|
|
Data shows: pre-event distribution (OBV Q1) has 56.4% WR vs accumulation (Q5) 51.2%.
|
|
PEAD works best when stocks were sold off before the event — contrarian reversal.
|
|
"""
|
|
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_contrarian_obv=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15b(row: dict[str, Any]) -> float:
|
|
"""V15B = V5 + OBV + BB contrarian combined."""
|
|
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_contrarian_obv=True, use_contrarian_bb=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15c(row: dict[str, Any]) -> float:
|
|
"""V15C = V5 + OBV + BB + OU contrarian all three."""
|
|
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_contrarian_obv=True, use_contrarian_bb=True, use_ou_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15d(row: dict[str, Any]) -> float:
|
|
"""V15D = V5 + strong OBV contrarian boost (+0.08) for trade count increase."""
|
|
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_contrarian_obv_strong=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15e(row: dict[str, Any]) -> float:
|
|
"""V15E = V5 + contrarian OBV/BB + entropy bonus."""
|
|
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_contrarian_obv=True, use_contrarian_bb=True, use_entropy_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v15f(row: dict[str, Any]) -> float:
|
|
"""V15F = V5 + ALL contrarian signals reversed (RSI low=bonus, BB low=bonus, OBV low=bonus)."""
|
|
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_contrarian_obv=True, use_contrarian_bb=True, use_contrarian_rsi=True,
|
|
use_ou_bonus=True, use_entropy_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v14(row: dict[str, Any]) -> float:
|
|
"""V14 = V5 + Tier 3: OU theta + gravitational pull + market temperature."""
|
|
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_tier3_bonuses=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v14_ou(row: dict[str, Any]) -> float:
|
|
"""V14_OU = V5 + OU theta bonus only."""
|
|
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_ou_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v14_gp(row: dict[str, Any]) -> float:
|
|
"""V14_GP = V5 + gravitational pull bonus only."""
|
|
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_grav_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v14_mt(row: dict[str, Any]) -> float:
|
|
"""V14_MT = V5 + market temperature bonus only."""
|
|
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_temp_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v14e(row: dict[str, Any]) -> float:
|
|
"""V14E = V5 + Tier 3 + Entropy (best of Tier 2)."""
|
|
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_tier3_bonuses=True, use_entropy_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v13(row: dict[str, Any]) -> float:
|
|
"""V13 = V5 + Tier 2 bonuses: Hurst trending + low entropy + sector tailwind."""
|
|
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_tier2_bonuses=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v13h(row: dict[str, Any]) -> float:
|
|
"""V13H = V5 + Hurst trending bonus only."""
|
|
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_hurst_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v13e(row: dict[str, Any]) -> float:
|
|
"""V13E = V5 + low entropy bonus only."""
|
|
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_entropy_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v13s(row: dict[str, Any]) -> float:
|
|
"""V13S = V5 + sector tailwind bonus only."""
|
|
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_sector_bonus=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v12(row: dict[str, Any]) -> float:
|
|
"""V12 = V5 + technical indicator adjustments (RSI, BB, OBV).
|
|
|
|
Soft adjustments to score based on pre-event technical state:
|
|
- RSI > 70 (overbought): -10% penalty (mean reversion risk)
|
|
- BB %B > 1.0 (above upper band): -15% penalty
|
|
- RSI < 30 + positive reaction (oversold reversal): +5% bonus
|
|
- OBV slope > 0 (accumulation): +3% bonus
|
|
- OBV slope < -0.5 (distribution): -2% penalty
|
|
"""
|
|
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_technical_gates=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v12r(row: dict[str, Any]) -> float:
|
|
"""V12R = V5 + RSI penalty only (no BB/OBV)."""
|
|
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_rsi_penalty=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v12b(row: dict[str, Any]) -> float:
|
|
"""V12B = V5 + BB penalty only."""
|
|
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_bb_penalty=True,
|
|
)
|
|
|
|
|
|
def compute_return_max_long_score_v12o(row: dict[str, Any]) -> float:
|
|
"""V12O = V5 + OBV conviction bonus only."""
|
|
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_obv_bonus=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,
|
|
prior_drift_weight: float = 0.0,
|
|
macro_regime_weight: float = 0.0,
|
|
positive_only_aux_bonus: bool = False,
|
|
use_earnings_surprise_bonus: bool = False,
|
|
use_technical_gates: bool = False,
|
|
use_rsi_penalty: bool = False,
|
|
use_bb_penalty: bool = False,
|
|
use_obv_bonus: bool = False,
|
|
use_tier2_bonuses: bool = False,
|
|
use_hurst_bonus: bool = False,
|
|
use_entropy_bonus: bool = False,
|
|
use_sector_bonus: bool = False,
|
|
use_tier3_bonuses: bool = False,
|
|
use_ou_bonus: bool = False,
|
|
use_grav_bonus: bool = False,
|
|
use_temp_bonus: bool = False,
|
|
use_contrarian_obv: bool = False,
|
|
use_contrarian_obv_strong: bool = False,
|
|
use_contrarian_bb: bool = False,
|
|
use_contrarian_rsi: 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 prior_drift_weight > 0.0:
|
|
prior_signal = _prior_drift_momentum_score(row)
|
|
if positive_only_aux_bonus:
|
|
prior_signal = max(0.0, prior_signal)
|
|
raw += prior_signal * prior_drift_weight
|
|
|
|
if macro_regime_weight > 0.0:
|
|
macro_signal = _macro_regime_score(row)
|
|
if positive_only_aux_bonus:
|
|
macro_signal = max(0.0, macro_signal)
|
|
raw += macro_signal * macro_regime_weight
|
|
|
|
if use_earnings_surprise_bonus:
|
|
raw += _earnings_surprise_bonus(row) * 0.10
|
|
|
|
# Technical indicator adjustments
|
|
if use_technical_gates or use_rsi_penalty:
|
|
rsi = _safe_float(row.get("pre_event_rsi_14"))
|
|
if rsi is not None:
|
|
if rsi > 70:
|
|
raw *= 0.90 # overbought penalty
|
|
elif rsi < 30:
|
|
reaction = _safe_float(row.get("reaction_day_return"))
|
|
if reaction is not None and reaction > 0:
|
|
raw *= 1.05 # oversold reversal bonus
|
|
|
|
if use_technical_gates or use_bb_penalty:
|
|
bb = _safe_float(row.get("pre_event_bb_position"))
|
|
if bb is not None and bb > 1.0:
|
|
raw *= 0.85 # above upper band penalty
|
|
|
|
if use_technical_gates or use_obv_bonus:
|
|
obv = _safe_float(row.get("pre_event_obv_slope_20d"))
|
|
if obv is not None:
|
|
if obv > 0:
|
|
raw += 0.03 # accumulation bonus
|
|
elif obv < -0.5:
|
|
raw -= 0.02 # strong distribution penalty
|
|
|
|
# Tier 2 bonuses: Hurst, Entropy, Sector
|
|
if use_tier2_bonuses or use_hurst_bonus:
|
|
hurst = _safe_float(row.get("pre_event_hurst_60d"))
|
|
if hurst is not None:
|
|
if hurst > 0.55:
|
|
raw += 0.03 # trending = PEAD friendly
|
|
elif hurst < 0.45:
|
|
raw -= 0.02 # mean-reverting = PEAD unfriendly
|
|
|
|
if use_tier2_bonuses or use_entropy_bonus:
|
|
entropy = _safe_float(row.get("pre_event_entropy_60d"))
|
|
if entropy is not None:
|
|
# Lower entropy = more predictable. Typical range ~1.5-2.3
|
|
if entropy < 1.8:
|
|
raw += 0.03 # predictable patterns bonus
|
|
elif entropy > 2.2:
|
|
raw -= 0.02 # chaotic = harder to predict
|
|
|
|
if use_tier2_bonuses or use_sector_bonus:
|
|
sector_mom = _safe_float(row.get("pre_event_sector_momentum_20d"))
|
|
if sector_mom is not None:
|
|
if sector_mom > 0.02:
|
|
raw += 0.03 # strong sector tailwind
|
|
elif sector_mom > 0:
|
|
raw += 0.01 # mild tailwind
|
|
elif sector_mom < -0.02:
|
|
raw -= 0.02 # sector headwind
|
|
|
|
# Tier 3 bonuses: OU theta, Gravitational Pull, Market Temperature
|
|
if use_tier3_bonuses or use_ou_bonus:
|
|
ou = _safe_float(row.get("pre_event_ou_theta_60d"))
|
|
if ou is not None:
|
|
if ou < 0.02:
|
|
raw += 0.03 # very slow reversion = PEAD friendly
|
|
elif ou < 0.05:
|
|
raw += 0.01 # slow reversion
|
|
elif ou > 0.15:
|
|
raw -= 0.02 # fast reversion = PEAD unfriendly
|
|
|
|
if use_tier3_bonuses or use_grav_bonus:
|
|
gp = _safe_float(row.get("pre_event_gravitational_pull"))
|
|
if gp is not None:
|
|
if gp < 0.5:
|
|
raw += 0.03 # near MAs = stable = PEAD friendly
|
|
elif gp < 1.0:
|
|
raw += 0.01 # reasonably close
|
|
elif gp > 2.0:
|
|
raw -= 0.02 # far from MAs = strong pull back risk
|
|
|
|
if use_tier3_bonuses or use_temp_bonus:
|
|
temp = _safe_float(row.get("pre_event_market_temperature"))
|
|
if temp is not None:
|
|
if temp < 0.8:
|
|
raw += 0.03 # cooling = orderly = PEAD friendly
|
|
elif temp < 1.0:
|
|
raw += 0.01 # mildly cool
|
|
elif temp > 1.5:
|
|
raw -= 0.02 # overheating = chaotic
|
|
|
|
# Contrarian bonuses: REVERSED direction based on data analysis
|
|
# OBV Q1 (distribution) = 56.4% WR vs Q5 (accumulation) = 51.2%
|
|
if use_contrarian_obv or use_contrarian_obv_strong:
|
|
obv = _safe_float(row.get("pre_event_obv_slope_20d"))
|
|
if obv is not None:
|
|
boost = 0.08 if use_contrarian_obv_strong else 0.04
|
|
if obv < -0.0085: # Q1 threshold: strong distribution
|
|
raw += boost
|
|
elif obv < 0: # mild distribution
|
|
raw += boost * 0.5
|
|
elif obv > 0.01: # accumulation = actually WORSE for PEAD
|
|
raw -= 0.02
|
|
|
|
# BB Q1 (below midline) = 54.9% WR — room to run after positive event
|
|
if use_contrarian_bb:
|
|
bb = _safe_float(row.get("pre_event_bb_position"))
|
|
if bb is not None:
|
|
if bb < 0.46: # Q1 threshold
|
|
raw += 0.03
|
|
elif bb < 0.5: # below midline
|
|
raw += 0.01
|
|
elif bb > 1.0: # above upper band = overextended
|
|
raw -= 0.02
|
|
|
|
# RSI contrarian: low RSI (oversold) = better PEAD (55.4% WR)
|
|
if use_contrarian_rsi:
|
|
rsi = _safe_float(row.get("pre_event_rsi_14"))
|
|
if rsi is not None:
|
|
if rsi < 40: # oversold zone
|
|
raw += 0.03
|
|
elif rsi < 50: # below midline
|
|
raw += 0.01
|
|
elif rsi > 70: # overbought = PEAD weaker
|
|
raw -= 0.02
|
|
|
|
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 _earnings_surprise_bonus(row: dict[str, Any]) -> float:
|
|
"""Bonus from earnings surprise (actual vs estimated EPS).
|
|
|
|
Empirical finding: small beats (0-3%) have 82.4% WR vs 54.8% for big beats.
|
|
Moderate surprise creates strongest PEAD (gradual repricing).
|
|
"""
|
|
event_type = str(row.get("event_type", "")).lower()
|
|
if event_type != "earnings_release":
|
|
return 0.0
|
|
|
|
surprise = _safe_float(row.get("earnings_surprise_pct"))
|
|
if surprise is None:
|
|
return 0.0
|
|
|
|
if 0 < surprise <= 3:
|
|
return 1.0 # sweet spot: moderate beat
|
|
elif 3 < surprise <= 8:
|
|
return 0.5 # decent beat but partially priced in
|
|
elif surprise > 8:
|
|
return 0.0 # big beat = already priced in
|
|
elif surprise <= 0:
|
|
return -0.5 # miss = penalty
|
|
|
|
return 0.0
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
def compute_oversold_bounce_score(row: dict[str, Any]) -> float:
|
|
"""Score for oversold bounce after negative earnings reaction.
|
|
|
|
Designed for contrarian mean-reversion trades where:
|
|
- Large-cap stock drops on mixed/negative event
|
|
- High volume confirms institutional selling (not just noise)
|
|
- Market cap provides floor for recovery
|
|
|
|
No direction or reaction-sign gates — the whole point is negative reaction.
|
|
"""
|
|
event_type = str(row.get("event_type", "")).lower()
|
|
if event_type not in {"earnings_release", "guidance_update", "other_material_event"}:
|
|
return 0.0
|
|
|
|
reaction = _safe_float(row.get("reaction_day_return"))
|
|
if reaction is None or reaction > -0.03: # must be negative
|
|
return 0.0
|
|
if reaction < -0.25: # too extreme, might be real bad news
|
|
return 0.0
|
|
|
|
parse_conf = _safe_float(row.get("parse_confidence_overall"))
|
|
if parse_conf is not None and parse_conf < 0.30:
|
|
return 0.0
|
|
|
|
# Score components for bounce quality
|
|
# 1. Reaction magnitude (40%) — bigger drop = more bounce potential
|
|
reaction_score = min(1.0, abs(reaction) / 0.15)
|
|
|
|
# 2. Volume (30%) — high volume = institutional selling = stronger signal
|
|
vol = _safe_float(row.get("volume_ratio_20d"))
|
|
vol_score = min(1.0, (vol or 1.0) / 4.0) if vol else 0.3
|
|
|
|
# 3. Close location (30%) — higher close = buying into close = bounce starting
|
|
cl = _safe_float(row.get("close_location"))
|
|
cl_score = _normalize_linear(cl, 0.1, 0.6) if cl is not None else 0.3
|
|
|
|
raw = reaction_score * 0.40 + vol_score * 0.30 + cl_score * 0.30
|
|
return _clamp(raw * 0.8) # scale down to avoid competing with main strategy
|
|
|
|
def compute_return_max_long_score_v11_surprise(row: dict[str, Any]) -> float:
|
|
"""Legacy experimental variant: V5 + earnings surprise bonus.
|
|
|
|
Uses actual vs estimated EPS surprise from Oracle earnings/surprise API.
|
|
Empirical finding: small beats (0-3%) have 82.4% WR vs 54.8% for big beats.
|
|
Moderate surprise = strongest PEAD drift (gradual repricing).
|
|
"""
|
|
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_earnings_surprise_bonus=True,
|
|
)
|