|
|
"""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,
|
|
|
}
|
|
|
|
|
|
_RETURN_MAX_LONG_ML_V2_FEATURES = (
|
|
|
"reaction_day_return",
|
|
|
"gap_size",
|
|
|
"close_location",
|
|
|
"volume_ratio_20d",
|
|
|
"document_quality_score",
|
|
|
"parse_confidence_overall",
|
|
|
"oneoff_penalty",
|
|
|
"market_cap_proxy",
|
|
|
"avg_dollar_volume_20d",
|
|
|
"pre_event_entropy_60d",
|
|
|
"pre_event_gravitational_pull",
|
|
|
"pre_event_hurst_60d",
|
|
|
"pre_event_market_temperature",
|
|
|
"pre_event_ou_theta_60d",
|
|
|
"pre_event_volatility_20d",
|
|
|
"pre_event_rsi_14",
|
|
|
"pre_event_bb_position",
|
|
|
"pre_event_obv_slope_20d",
|
|
|
"macro_vix",
|
|
|
"macro_hy_spread",
|
|
|
"macro_t10y2y",
|
|
|
"prior_event_fwd5d",
|
|
|
"lm_positive_pct",
|
|
|
"lm_negative_pct",
|
|
|
"lm_net_sentiment",
|
|
|
"earnings_surprise_pct",
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V2_MEDIANS = (
|
|
|
0.00043796268831433567,
|
|
|
0.0014147341847357661,
|
|
|
0.491144432431328,
|
|
|
1.7471348973965604,
|
|
|
0.5652,
|
|
|
0.538,
|
|
|
0.3333333333333333,
|
|
|
33840252928.0,
|
|
|
211056559.90320206,
|
|
|
1.8656900250589923,
|
|
|
1.646267696185956,
|
|
|
0.5997768089966802,
|
|
|
0.9473339634670879,
|
|
|
0.07390133614831655,
|
|
|
0.01756562718850168,
|
|
|
53.54057861323522,
|
|
|
0.5891477752380258,
|
|
|
0.05511243484782039,
|
|
|
15.84,
|
|
|
3.2,
|
|
|
-0.21,
|
|
|
0.005737396762029504,
|
|
|
0.002101,
|
|
|
0.001027,
|
|
|
0.0004285,
|
|
|
5.41,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V2_MEANS = (
|
|
|
0.0032582434636562216,
|
|
|
0.0035941803987159814,
|
|
|
0.49789102050187767,
|
|
|
2.2204886406770665,
|
|
|
0.5765680487804878,
|
|
|
0.5558378048780488,
|
|
|
0.4033536585365854,
|
|
|
121359939981.15121,
|
|
|
569833415.8599782,
|
|
|
1.8111128627932631,
|
|
|
1.9178768731718259,
|
|
|
0.5993916206513497,
|
|
|
0.9772700012692805,
|
|
|
0.0930214002992398,
|
|
|
0.021797492929273746,
|
|
|
53.27715050355677,
|
|
|
0.5612362654662348,
|
|
|
0.05220690800032629,
|
|
|
16.741275914634148,
|
|
|
3.3686143292682926,
|
|
|
-0.181782012195122,
|
|
|
0.0070169607624383165,
|
|
|
0.004126396341463415,
|
|
|
0.002112548475609756,
|
|
|
0.002011094512195122,
|
|
|
9.073597530487804,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V2_SCALES = (
|
|
|
0.0720229227164041,
|
|
|
0.059017403591568034,
|
|
|
0.30109097079611835,
|
|
|
1.7791554894359867,
|
|
|
0.11551029730719951,
|
|
|
0.12749633867554488,
|
|
|
0.36849681780978466,
|
|
|
376537958659.33057,
|
|
|
2019713069.1204512,
|
|
|
0.25865153729782864,
|
|
|
1.4260850821637705,
|
|
|
0.1349994583356394,
|
|
|
0.38424055351326725,
|
|
|
0.07999123225095692,
|
|
|
0.016667513800963603,
|
|
|
17.822845453925048,
|
|
|
0.4065215223535034,
|
|
|
0.24174830009237133,
|
|
|
3.656293322040498,
|
|
|
0.6370337970431081,
|
|
|
0.3353366683496654,
|
|
|
0.04836133779385634,
|
|
|
0.004925452240154967,
|
|
|
0.002937533648395367,
|
|
|
0.005087427053986574,
|
|
|
162.48548260791046,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V2_COEFFICIENTS = (
|
|
|
0.12267321524336684,
|
|
|
-0.09758984044985207,
|
|
|
-0.14089590256871018,
|
|
|
0.20920411932983002,
|
|
|
0.36849774883885045,
|
|
|
-0.3065371678751244,
|
|
|
-0.0097733831921906,
|
|
|
-0.12338809439110936,
|
|
|
0.2721540850531583,
|
|
|
0.2748841199015877,
|
|
|
-0.19359210589527456,
|
|
|
0.020699991539144882,
|
|
|
-0.1372657179049571,
|
|
|
-0.09676797576582977,
|
|
|
2.2289710150127107,
|
|
|
0.10555479229614609,
|
|
|
-0.10817108771075115,
|
|
|
-0.10294022818336451,
|
|
|
0.053637765359040426,
|
|
|
0.0316402350515546,
|
|
|
0.28702444965666424,
|
|
|
-0.01565075524901778,
|
|
|
0.08130366466684952,
|
|
|
-0.03591552791019765,
|
|
|
-0.0834092973465427,
|
|
|
0.9519857171253503,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V2_INTERCEPT = 0.34221813652373806
|
|
|
|
|
|
_RETURN_MAX_LONG_ML_V3_FEATURES = (
|
|
|
"reaction_day_return",
|
|
|
"gap_size",
|
|
|
"close_location",
|
|
|
"volume_ratio_20d",
|
|
|
"document_quality_score",
|
|
|
"parse_confidence_overall",
|
|
|
"oneoff_penalty",
|
|
|
"market_cap_proxy",
|
|
|
"avg_dollar_volume_20d",
|
|
|
"pre_event_entropy_60d",
|
|
|
"pre_event_gravitational_pull",
|
|
|
"pre_event_hurst_60d",
|
|
|
"pre_event_market_temperature",
|
|
|
"pre_event_ou_theta_60d",
|
|
|
"pre_event_volatility_20d",
|
|
|
"pre_event_rsi_14",
|
|
|
"pre_event_bb_position",
|
|
|
"pre_event_obv_slope_20d",
|
|
|
"macro_vix",
|
|
|
"macro_hy_spread",
|
|
|
"macro_t10y2y",
|
|
|
"prior_event_fwd5d",
|
|
|
"lm_positive_pct",
|
|
|
"lm_negative_pct",
|
|
|
"lm_net_sentiment",
|
|
|
"earnings_surprise_pct",
|
|
|
"sue_lag_1_pct",
|
|
|
"sue_lag_2_pct",
|
|
|
"sue_lag_3_pct",
|
|
|
"sue_hist_mean_4q",
|
|
|
"sue_hist_mean_8q",
|
|
|
"sue_hist_mean_12q",
|
|
|
"sue_hist_pos_rate_4q",
|
|
|
"sue_hist_pos_rate_12q",
|
|
|
"sue_hist_latest_pct",
|
|
|
"sue_hist_streak_pos",
|
|
|
"peer_sector_event_count_365d",
|
|
|
"peer_sector_surprise_median_365d",
|
|
|
"peer_sector_surprise_mean_365d",
|
|
|
"peer_sector_surprise_pos_rate_365d",
|
|
|
"peer_relative_surprise_pct_365d",
|
|
|
"peer_sector_sue_hist_mean_4q_median_365d",
|
|
|
"peer_sector_sue_hist_mean_4q_mean_365d",
|
|
|
"peer_relative_sue_hist_mean_4q_365d",
|
|
|
"peer_sector_sue_hist_pos_rate_4q_mean_365d",
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V3_MEDIANS = (
|
|
|
0.00043796268831433567,
|
|
|
0.0014147341847357661,
|
|
|
0.491144432431328,
|
|
|
1.7471348973965604,
|
|
|
0.5652,
|
|
|
0.538,
|
|
|
0.3333333333333333,
|
|
|
33840252928.0,
|
|
|
211056559.90320206,
|
|
|
1.8656900250589923,
|
|
|
1.646267696185956,
|
|
|
0.5997768089966802,
|
|
|
0.9473339634670879,
|
|
|
0.07390133614831655,
|
|
|
0.01756562718850168,
|
|
|
53.54057861323522,
|
|
|
0.5891477752380258,
|
|
|
0.05511243484782039,
|
|
|
15.84,
|
|
|
3.2,
|
|
|
-0.21,
|
|
|
0.005737396762029504,
|
|
|
0.002101,
|
|
|
0.001027,
|
|
|
0.0004285,
|
|
|
5.41,
|
|
|
5.74,
|
|
|
6.35,
|
|
|
6.78,
|
|
|
6.5225,
|
|
|
6.5225,
|
|
|
6.5225,
|
|
|
1.0,
|
|
|
1.0,
|
|
|
5.71715,
|
|
|
1.0,
|
|
|
5.0,
|
|
|
8.11,
|
|
|
9.238888888888889,
|
|
|
0.8305084745762712,
|
|
|
-0.5930750000000002,
|
|
|
6.46,
|
|
|
14.167990021929825,
|
|
|
0.22333333333333272,
|
|
|
0.7869047619047619,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V3_MEANS = (
|
|
|
0.0032582434636562216,
|
|
|
0.0035941803987159814,
|
|
|
0.49789102050187767,
|
|
|
2.2204886406770665,
|
|
|
0.5765680487804878,
|
|
|
0.5558378048780488,
|
|
|
0.4033536585365854,
|
|
|
121359939981.15121,
|
|
|
569833415.8599782,
|
|
|
1.8111128627932631,
|
|
|
1.9178768731718259,
|
|
|
0.5993916206513497,
|
|
|
0.9772700012692805,
|
|
|
0.0930214002992398,
|
|
|
0.021797492929273746,
|
|
|
53.27715050355677,
|
|
|
0.5612362654662348,
|
|
|
0.05220690800032629,
|
|
|
16.741275914634148,
|
|
|
3.3686143292682926,
|
|
|
-0.181782012195122,
|
|
|
0.0070169607624383165,
|
|
|
0.004126396341463415,
|
|
|
0.002112548475609756,
|
|
|
0.002011094512195122,
|
|
|
9.073597530487804,
|
|
|
3.5187182012195115,
|
|
|
6.851466509146342,
|
|
|
6.883917957317073,
|
|
|
5.594755612296748,
|
|
|
5.594755612296748,
|
|
|
5.594755612296748,
|
|
|
0.9639481707317074,
|
|
|
0.9639481707317074,
|
|
|
3.498086951219514,
|
|
|
1.0644817073170731,
|
|
|
12.055335365853658,
|
|
|
7.959569230182926,
|
|
|
11.22887578249851,
|
|
|
0.8254561829513042,
|
|
|
2.338585868902439,
|
|
|
6.587968925304877,
|
|
|
13.995408413628205,
|
|
|
-0.7883605995934964,
|
|
|
0.7870381616238329,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V3_SCALES = (
|
|
|
0.0720229227164041,
|
|
|
0.059017403591568034,
|
|
|
0.30109097079611835,
|
|
|
1.7791554894359867,
|
|
|
0.11551029730719951,
|
|
|
0.12749633867554488,
|
|
|
0.36849681780978466,
|
|
|
376537958659.33057,
|
|
|
2019713069.1204512,
|
|
|
0.25865153729782864,
|
|
|
1.4260850821637705,
|
|
|
0.1349994583356394,
|
|
|
0.38424055351326725,
|
|
|
0.07999123225095692,
|
|
|
0.016667513800963603,
|
|
|
17.822845453925048,
|
|
|
0.4065215223535034,
|
|
|
0.24174830009237133,
|
|
|
3.656293322040498,
|
|
|
0.6370337970431081,
|
|
|
0.3353366683496654,
|
|
|
0.04836133779385634,
|
|
|
0.004925452240154967,
|
|
|
0.002937533648395367,
|
|
|
0.005087427053986574,
|
|
|
162.48548260791046,
|
|
|
222.61038856413487,
|
|
|
74.59061476608642,
|
|
|
50.26701526769874,
|
|
|
134.7529157601218,
|
|
|
134.7529157601218,
|
|
|
134.7529157601218,
|
|
|
0.16129628605046287,
|
|
|
0.16129628605046287,
|
|
|
222.61020493014973,
|
|
|
0.438213571003591,
|
|
|
21.530398650421372,
|
|
|
2.1857798642883264,
|
|
|
12.6082138253217,
|
|
|
0.06541432061390462,
|
|
|
161.4887497381566,
|
|
|
3.187426548400984,
|
|
|
16.560638169447746,
|
|
|
134.75182576886135,
|
|
|
0.050455645804338346,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V3_COEFFICIENTS = (
|
|
|
0.14007421317292182,
|
|
|
-0.12003815541366457,
|
|
|
-0.14717276967184498,
|
|
|
0.18313676269153908,
|
|
|
0.14179813559884488,
|
|
|
-0.09000853444472605,
|
|
|
-0.046581801850541525,
|
|
|
-0.12284271029788699,
|
|
|
0.26525002968260414,
|
|
|
0.2710189723794223,
|
|
|
-0.19222136981416527,
|
|
|
0.02464053107964807,
|
|
|
-0.13718507142293965,
|
|
|
-0.09866125296485219,
|
|
|
2.2135404380489256,
|
|
|
0.10638301315964269,
|
|
|
-0.10767923785991367,
|
|
|
-0.1089426720380704,
|
|
|
0.03699092632363811,
|
|
|
0.07117187147333857,
|
|
|
0.27285960273776827,
|
|
|
-0.01103334295655712,
|
|
|
0.03060055361239007,
|
|
|
0.0038679021609147627,
|
|
|
-0.03316393853491961,
|
|
|
1.0013440947591314,
|
|
|
-0.052553942952412135,
|
|
|
0.0208667186502828,
|
|
|
-0.009008618193599565,
|
|
|
-0.007862018406960523,
|
|
|
-0.007862018406960523,
|
|
|
-0.007862018406960523,
|
|
|
-0.000176632177643848,
|
|
|
-0.000176632177643848,
|
|
|
-0.04509124507569923,
|
|
|
-0.031925265957652584,
|
|
|
0.0912446875408573,
|
|
|
-0.052360698463861294,
|
|
|
0.11784159306631489,
|
|
|
-0.007059253375004289,
|
|
|
-0.10963120188333718,
|
|
|
-0.02166764961835262,
|
|
|
0.03822657454068858,
|
|
|
0.15606421258538145,
|
|
|
0.07028148651563548,
|
|
|
)
|
|
|
_RETURN_MAX_LONG_ML_V3_INTERCEPT = 0.3435877520237345
|
|
|
|
|
|
|
|
|
def _compute_numeric_logistic_score(
|
|
|
row: dict[str, Any],
|
|
|
*,
|
|
|
features: tuple[str, ...],
|
|
|
medians: tuple[float, ...],
|
|
|
means: tuple[float, ...],
|
|
|
scales: tuple[float, ...],
|
|
|
coefficients: tuple[float, ...],
|
|
|
intercept: float,
|
|
|
) -> float:
|
|
|
import math
|
|
|
|
|
|
reaction = _safe_float(row.get("reaction_day_return"))
|
|
|
if reaction is None:
|
|
|
return 0.0
|
|
|
|
|
|
event_type = str(row.get("event_type", "")).lower()
|
|
|
if event_type not in {
|
|
|
"earnings_release",
|
|
|
"guidance_update",
|
|
|
"material_contract",
|
|
|
"other_material_event",
|
|
|
"management_change",
|
|
|
"unknown",
|
|
|
}:
|
|
|
return 0.0
|
|
|
|
|
|
z = intercept
|
|
|
for idx, feature in enumerate(features):
|
|
|
value = _safe_float(row.get(feature))
|
|
|
if value is None:
|
|
|
value = medians[idx]
|
|
|
scaled = (value - means[idx]) / max(scales[idx], 1e-12)
|
|
|
z += scaled * coefficients[idx]
|
|
|
|
|
|
z = max(min(z, 60.0), -60.0)
|
|
|
return _clamp(1.0 / (1.0 + math.exp(-z)))
|
|
|
|
|
|
|
|
|
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_v13f(row: dict[str, Any]) -> float:
|
|
|
"""V13F = V13E + sector tailwind bonus.
|
|
|
|
|
|
Combines low entropy (predictability) with sector momentum.
|
|
|
Empirically: on v13e-trade-eligible subset (score >= 0.42), the top
|
|
|
quartile of pre_event_sector_momentum_20d shows ~+11pp WR vs other
|
|
|
quartiles on both valid and test splits.
|
|
|
"""
|
|
|
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,
|
|
|
use_sector_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_surp(row: dict[str, Any]) -> float:
|
|
|
"""V13E_SURP = V13E + earnings surprise bonus.
|
|
|
|
|
|
Adds actual vs estimated EPS surprise signal on top of V13E.
|
|
|
Sweet spot: 0-3% beat → strongest PEAD (gradual repricing).
|
|
|
Note: ~22% coverage for earnings_release events; null returns 0.0.
|
|
|
"""
|
|
|
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,
|
|
|
use_earnings_surprise_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_pd(row: dict[str, Any]) -> float:
|
|
|
"""V13E_PD = V13E + prior drift positive-only bonus.
|
|
|
|
|
|
Cross-event momentum: prior same-ticker 5d drift > +2% → +5% bonus.
|
|
|
Positive-only (no penalty). 76% coverage (null → 0).
|
|
|
Empirical: 8.1pp WR spread between positive vs negative prior drift.
|
|
|
"""
|
|
|
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,
|
|
|
prior_drift_weight=0.05,
|
|
|
positive_only_aux_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_mr(row: dict[str, Any]) -> float:
|
|
|
"""V13E_MR = V13E + macro regime positive-only bonus.
|
|
|
|
|
|
VIX > 18 AND HY spread > 3.25 → fear regime → +5% bonus.
|
|
|
Positive-only (no penalty). 100% coverage.
|
|
|
Empirical: 62.3% WR in fear regime vs 50.8% otherwise (11.5pp spread).
|
|
|
"""
|
|
|
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,
|
|
|
macro_regime_weight=0.05,
|
|
|
positive_only_aux_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_pdmr(row: dict[str, Any]) -> float:
|
|
|
"""V13E_PDMR = V13E + prior drift + macro regime (both positive-only).
|
|
|
|
|
|
Combined: prior drift bonus + macro fear regime bonus. V11 style on top of V13E.
|
|
|
Both scaled 0.05, positive_only (no penalties from either signal).
|
|
|
"""
|
|
|
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,
|
|
|
prior_drift_weight=0.05,
|
|
|
macro_regime_weight=0.05,
|
|
|
positive_only_aux_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_pd2(row: dict[str, Any]) -> float:
|
|
|
"""V13E_PD2 = V13E + prior drift bidirectional (bonus + penalty).
|
|
|
|
|
|
Bidirectional: positive prior drift → +10% bonus, negative → -10% penalty.
|
|
|
More aggressive use of 8.1pp WR spread vs positive-only version.
|
|
|
"""
|
|
|
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,
|
|
|
prior_drift_weight=0.10,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_h(row: dict[str, Any]) -> float:
|
|
|
"""V13E_H = V13E + Hurst trending bonus.
|
|
|
|
|
|
Hurst > 0.55 (trending) → +3% bonus: PEAD drift more likely to persist.
|
|
|
Hurst < 0.45 (mean-reverting) → -2% penalty: drift tends to fade quickly.
|
|
|
"""
|
|
|
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,
|
|
|
use_hurst_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_ou(row: dict[str, Any]) -> float:
|
|
|
"""V13E_OU = V13E + OU theta bonus.
|
|
|
|
|
|
OU theta < 0.02 (slow mean-reversion) → +3%: price stays at new level longer.
|
|
|
OU theta > 0.15 (fast mean-reversion) → -2%: drift dissipates quickly.
|
|
|
"""
|
|
|
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,
|
|
|
use_ou_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_t3(row: dict[str, Any]) -> float:
|
|
|
"""V13E_T3 = V13E + tier3 bonuses (OU + gravitational pull + market temperature).
|
|
|
|
|
|
Three tier3 signals combined: slow mean-reversion (OU), proximity to MAs (grav),
|
|
|
and market temperature regime (temp). Each ±2–3%.
|
|
|
"""
|
|
|
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,
|
|
|
use_tier3_bonuses=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_t23(row: dict[str, Any]) -> float:
|
|
|
"""V13E_T23 = V13E + all tier2+tier3 bonuses.
|
|
|
|
|
|
Full bonus stack: hurst + entropy + sector momentum (tier2) +
|
|
|
OU + gravitational pull + market temperature (tier3).
|
|
|
Note: use_tier2_bonuses includes entropy, so no separate use_entropy_bonus needed.
|
|
|
"""
|
|
|
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,
|
|
|
use_tier3_bonuses=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_qsc(row: dict[str, Any]) -> float:
|
|
|
"""V13E_QSC = V13E + Quantum Signal Coherence (multiplicative).
|
|
|
|
|
|
Physics: quantum wave function coherence / constructive interference.
|
|
|
7 binary signals (reaction, close_location, volume, doc_quality,
|
|
|
guidance, entropy, hurst) → coherence fraction → ×0.85 to ×1.15 scaler.
|
|
|
Measures second-order AGREEMENT across signals, orthogonal to base 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,
|
|
|
use_entropy_bonus=True,
|
|
|
use_coherence_scaling=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_btg(row: dict[str, Any]) -> float:
|
|
|
"""V13E_BTG = V13E + Boltzmann Temperature Gate (multiplicative).
|
|
|
|
|
|
Physics: statistical mechanics Boltzmann distribution — low temperature
|
|
|
→ ordered ground state, high temperature → excited/chaotic state.
|
|
|
Market temperature = vol_5d/vol_20d.
|
|
|
Cold (<0.8) → ×1.10, hot (>1.3) → ×0.85, normal → ×1.0.
|
|
|
Affects 58% of trades; 2–3× stronger than prior ±0.03 additive 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_entropy_bonus=True,
|
|
|
use_temperature_gate=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v13e_hug(row: dict[str, Any]) -> float:
|
|
|
"""V13E_HUG = V13E + Heisenberg Uncertainty Gate (multiplicative).
|
|
|
|
|
|
Physics: Heisenberg uncertainty principle Δx·Δp ≥ ℏ/2 — simultaneous
|
|
|
precision in two conjugate quantities is limited.
|
|
|
Here: entropy (information uncertainty) × temperature (volatility uncertainty).
|
|
|
High product (>2.5, 14.8% of trades) → dual uncertainty → ×0.85.
|
|
|
Low product (<1.0, 14.8% of trades) → dual certainty → ×1.10.
|
|
|
Captures INTERACTION between features, not their independent additive effects.
|
|
|
"""
|
|
|
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,
|
|
|
use_uncertainty_gate=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v16(row: dict[str, Any]) -> float:
|
|
|
"""V16 = V13E + market cap bonus. Larger caps have stronger PEAD (data-validated)."""
|
|
|
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,
|
|
|
use_mcap_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v16e(row: dict[str, Any]) -> float:
|
|
|
"""V16E = V5 + market cap bonus only (no entropy)."""
|
|
|
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_mcap_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,
|
|
|
use_mcap_bonus: bool = False,
|
|
|
use_coherence_scaling: bool = False,
|
|
|
use_temperature_gate: bool = False,
|
|
|
use_uncertainty_gate: 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", "unknown"}:
|
|
|
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
|
|
|
|
|
|
# Market cap bonus: larger companies have stronger/more predictable PEAD
|
|
|
# Data: OME winners avg $348B vs losers $173B; mat_unknown winners $117B vs losers $43B
|
|
|
if use_mcap_bonus:
|
|
|
mcap = _safe_float(row.get("market_cap_proxy"))
|
|
|
if mcap is not None:
|
|
|
if mcap > 50e9:
|
|
|
raw += 0.04 # mega-cap: strongest PEAD signal
|
|
|
elif mcap > 20e9:
|
|
|
raw += 0.02 # large-cap: good signal
|
|
|
elif mcap < 5e9:
|
|
|
raw -= 0.02 # small-cap: weaker/noisier PEAD
|
|
|
|
|
|
# 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
|
|
|
|
|
|
# === Physics-inspired multiplicative scaling ===
|
|
|
# Unlike additive bonuses (±0.02-0.05), these scale the ENTIRE raw score
|
|
|
# by ±15%, which is large enough to reorder candidates near the threshold.
|
|
|
|
|
|
if use_coherence_scaling:
|
|
|
# Quantum Signal Coherence: measure agreement of 7 binary signals.
|
|
|
# When all signals align (constructive interference), boost score.
|
|
|
# When signals disagree (destructive interference), reduce score.
|
|
|
# coherence = fraction of signals that are "positive"
|
|
|
# adjusted = raw * (1 + 0.15 * (2*coherence - 1)) → ×0.85 to ×1.15
|
|
|
reaction_v = _safe_float(row.get("reaction_day_return"))
|
|
|
close_v = _safe_float(row.get("close_location"))
|
|
|
vol_v = _safe_float(row.get("volume_ratio_20d"))
|
|
|
dq_v = _safe_float(row.get("document_quality_score"))
|
|
|
gd_v = _safe_float(row.get("guidance_direction_score"))
|
|
|
ent_v = _safe_float(row.get("pre_event_entropy_60d"))
|
|
|
hurst_v = _safe_float(row.get("pre_event_hurst_60d"))
|
|
|
coherent_signals = [
|
|
|
reaction_v is not None and reaction_v > 0.05,
|
|
|
close_v is not None and close_v > 0.70,
|
|
|
vol_v is not None and vol_v > 1.5,
|
|
|
dq_v is not None and dq_v > 0.70,
|
|
|
gd_v is not None and gd_v > 0.50,
|
|
|
ent_v is not None and ent_v < 1.8,
|
|
|
hurst_v is not None and hurst_v > 0.55,
|
|
|
]
|
|
|
coherence = sum(coherent_signals) / len(coherent_signals)
|
|
|
raw *= 1.0 + 0.15 * (2.0 * coherence - 1.0)
|
|
|
|
|
|
if use_temperature_gate:
|
|
|
# Boltzmann Temperature Gate: cold markets → higher confidence,
|
|
|
# hot/chaotic markets → lower confidence.
|
|
|
# temp = vol_5d / vol_20d; cold(<0.8)×1.10, hot(>1.3)×0.85
|
|
|
temp_v = _safe_float(row.get("pre_event_market_temperature"))
|
|
|
if temp_v is not None:
|
|
|
if temp_v < 0.8:
|
|
|
raw *= 1.10
|
|
|
elif temp_v > 1.3:
|
|
|
raw *= 0.85
|
|
|
|
|
|
if use_uncertainty_gate:
|
|
|
# Heisenberg Uncertainty Gate: high entropy × high temperature → dual
|
|
|
# uncertainty reduces prediction confidence (multiplicative interaction).
|
|
|
# uncertainty_product > 2.5 → ×0.85; < 1.0 → ×1.10
|
|
|
ent_v2 = _safe_float(row.get("pre_event_entropy_60d"))
|
|
|
temp_v2 = _safe_float(row.get("pre_event_market_temperature"))
|
|
|
if ent_v2 is not None and temp_v2 is not None:
|
|
|
uncertainty = ent_v2 * temp_v2
|
|
|
if uncertainty > 2.5:
|
|
|
raw *= 0.85
|
|
|
elif uncertainty < 1.0:
|
|
|
raw *= 1.10
|
|
|
|
|
|
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_v17(row: dict[str, Any]) -> float:
|
|
|
"""V17 = V13E + contrarian OBV only.
|
|
|
|
|
|
Data analysis (6,560 train events): OBV Q1 (distribution) = 56.4% WR
|
|
|
vs Q5 (accumulation) = 51.2%. Contrarian signal: stocks with pre-event
|
|
|
institutional selling drift further after positive event.
|
|
|
"""
|
|
|
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, use_contrarian_obv=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v17b(row: dict[str, Any]) -> float:
|
|
|
"""V17B = V13E + contrarian OBV + contrarian BB.
|
|
|
|
|
|
OBV Q1 + BB Q1 combo = 58.1% WR (best dual signal).
|
|
|
"""
|
|
|
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, use_contrarian_obv=True, use_contrarian_bb=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v17c(row: dict[str, Any]) -> float:
|
|
|
"""V17C = V13E + all contrarian signals (OBV + BB + RSI) + OU theta.
|
|
|
|
|
|
Full contrarian reversal: every feature that was backwards is now corrected.
|
|
|
"""
|
|
|
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, use_contrarian_obv=True, use_contrarian_bb=True,
|
|
|
use_contrarian_rsi=True, use_ou_bonus=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v18(row: dict[str, Any]) -> float:
|
|
|
"""V18 = tier2+tier3 + full contrarian reversal.
|
|
|
|
|
|
This is the first "grand unified" long scorer: predictable/trending/orderly
|
|
|
states (entropy/hurst/OU/gravity/temperature) plus under-owned technical
|
|
|
posture (OBV/BB/RSI contrarian) inside one additive 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,
|
|
|
use_tier2_bonuses=True,
|
|
|
use_tier3_bonuses=True,
|
|
|
use_contrarian_obv=True,
|
|
|
use_contrarian_bb=True,
|
|
|
use_contrarian_rsi=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v18b(row: dict[str, Any]) -> float:
|
|
|
"""V18B = V18 + coherence scaling.
|
|
|
|
|
|
Physics analogy: when trend/predictability/technical signals align,
|
|
|
constructive interference should reorder near-threshold names.
|
|
|
"""
|
|
|
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,
|
|
|
use_tier3_bonuses=True,
|
|
|
use_contrarian_obv=True,
|
|
|
use_contrarian_bb=True,
|
|
|
use_contrarian_rsi=True,
|
|
|
use_coherence_scaling=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v18c(row: dict[str, Any]) -> float:
|
|
|
"""V18C = V18 + chaos penalties.
|
|
|
|
|
|
Penalizes hot/uncertain states multiplicatively, keeping the additive alpha
|
|
|
stack from overtrusting technically crowded but noisy setups.
|
|
|
"""
|
|
|
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,
|
|
|
use_tier3_bonuses=True,
|
|
|
use_contrarian_obv=True,
|
|
|
use_contrarian_bb=True,
|
|
|
use_contrarian_rsi=True,
|
|
|
use_temperature_gate=True,
|
|
|
use_uncertainty_gate=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_v18d(row: dict[str, Any]) -> float:
|
|
|
"""V18D = V18B + escape-velocity scaling.
|
|
|
|
|
|
Relativistic analogy: strong post-event impulse only persists when it can
|
|
|
overcome uncertainty/temperature/gravitational drag. High reaction in a low-
|
|
|
drag state gets boosted; weak reaction in a noisy state is scaled down.
|
|
|
"""
|
|
|
raw = _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,
|
|
|
use_tier3_bonuses=True,
|
|
|
use_contrarian_obv=True,
|
|
|
use_contrarian_bb=True,
|
|
|
use_contrarian_rsi=True,
|
|
|
use_coherence_scaling=True,
|
|
|
)
|
|
|
|
|
|
reaction = _safe_float(row.get("reaction_day_return")) or 0.0
|
|
|
entropy = _safe_float(row.get("pre_event_entropy_60d")) or 2.0
|
|
|
temperature = _safe_float(row.get("pre_event_market_temperature")) or 1.0
|
|
|
gravity = _safe_float(row.get("pre_event_gravitational_pull")) or 1.0
|
|
|
hurst = _safe_float(row.get("pre_event_hurst_60d")) or 0.5
|
|
|
obv = _safe_float(row.get("pre_event_obv_slope_20d")) or 0.0
|
|
|
bb = _safe_float(row.get("pre_event_bb_position")) or 0.5
|
|
|
rsi = _safe_float(row.get("pre_event_rsi_14")) or 50.0
|
|
|
|
|
|
drag = (
|
|
|
1.0
|
|
|
+ max(0.0, entropy - 1.85) * 0.70
|
|
|
+ max(0.0, temperature - 1.00) * 0.60
|
|
|
+ max(0.0, gravity - 1.00) * 0.25
|
|
|
)
|
|
|
|
|
|
contrarian_charge = 0.0
|
|
|
if obv < 0.0:
|
|
|
contrarian_charge += 0.25
|
|
|
if bb < 0.50:
|
|
|
contrarian_charge += 0.20
|
|
|
if rsi < 50.0:
|
|
|
contrarian_charge += 0.20
|
|
|
if hurst > 0.55:
|
|
|
contrarian_charge += 0.15
|
|
|
|
|
|
escape_velocity = reaction * (1.0 + contrarian_charge) / drag
|
|
|
if escape_velocity > 0.055:
|
|
|
raw *= 1.08
|
|
|
elif escape_velocity < 0.030:
|
|
|
raw *= 0.92
|
|
|
|
|
|
if entropy < 1.8 and temperature < 1.0 and hurst > 0.55:
|
|
|
raw *= 1.04
|
|
|
|
|
|
return _clamp(raw)
|
|
|
|
|
|
|
|
|
def compute_return_max_short_score_v1(row: dict[str, Any]) -> float:
|
|
|
"""Short-side PEAD scoring for bearish events.
|
|
|
|
|
|
Academic basis: negative PEAD drift is well-documented (Ball & Brown 1968,
|
|
|
Bernard & Thomas 1989). Stocks with strong negative reactions continue
|
|
|
to decline as the market gradually incorporates bad news.
|
|
|
|
|
|
Hard gates:
|
|
|
- reaction_day_return < -0.05 (strong bearish reaction)
|
|
|
- reaction_day_return > -0.25 (not catastrophic — those may bounce)
|
|
|
- volume_ratio > 1.5 (institutional conviction)
|
|
|
- parse_confidence >= 0.50
|
|
|
|
|
|
Components:
|
|
|
- Reaction magnitude (45%): bigger drop = stronger negative drift
|
|
|
- Volume conviction (25%): high volume = institutional selling
|
|
|
- Close location (15%): low close = selling into close = continued pressure
|
|
|
- Document quality (15%): higher quality parse = more reliable signal
|
|
|
|
|
|
Sets trade_direction = "short" as side effect.
|
|
|
"""
|
|
|
event_type = str(row.get("event_type", "")).lower()
|
|
|
if event_type not in {"earnings_release", "guidance_update", "material_contract",
|
|
|
"other_material_event", "unknown"}:
|
|
|
return 0.0
|
|
|
|
|
|
reaction = _safe_float(row.get("reaction_day_return"))
|
|
|
if reaction is None or reaction > -0.03 or reaction < -0.25:
|
|
|
return 0.0
|
|
|
|
|
|
vol = _safe_float(row.get("volume_ratio_20d"))
|
|
|
if vol is None or vol < 1.0:
|
|
|
return 0.0
|
|
|
|
|
|
parse_conf = _safe_float(row.get("parse_confidence_overall"))
|
|
|
if parse_conf is not None and parse_conf < 0.40:
|
|
|
return 0.0
|
|
|
|
|
|
# Set short direction
|
|
|
row["trade_direction"] = "short"
|
|
|
|
|
|
# 1. Reaction magnitude (45%) — bigger drop = stronger signal
|
|
|
abs_ret = abs(reaction)
|
|
|
reaction_score = min(1.0, (abs_ret - 0.05) / 0.10 * 0.6 + 0.5)
|
|
|
|
|
|
# 2. Volume conviction (25%)
|
|
|
vol_score = min(1.0, vol / 5.0)
|
|
|
|
|
|
# 3. Close location (15%) — low close = selling pressure into close
|
|
|
cl = _safe_float(row.get("close_location"))
|
|
|
if cl is not None:
|
|
|
cl_score = max(0.0, 1.0 - cl) # invert: lower close = higher score
|
|
|
else:
|
|
|
cl_score = 0.5
|
|
|
|
|
|
# 4. Document quality (15%)
|
|
|
doc_quality = _normalized(row.get("document_quality_score"))
|
|
|
sig_strength = _normalized(row.get("signal_strength_score"))
|
|
|
doc_score = max(doc_quality, sig_strength)
|
|
|
|
|
|
raw = (reaction_score * 0.45 + vol_score * 0.25
|
|
|
+ cl_score * 0.15 + doc_score * 0.15)
|
|
|
return _clamp(raw)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v1(row: dict[str, Any]) -> float:
|
|
|
"""ML-based scoring: logistic regression trained on 1,834 events.
|
|
|
|
|
|
Features: reaction, close_location, volume, gap, doc quality, signal strength,
|
|
|
parse confidence, guidance, oneoff, market cap, event type, direction, timing.
|
|
|
|
|
|
Train AUC: 0.707, Valid AUC: 0.729. Q5 88.3% strong drift vs Q1 37.3%.
|
|
|
Output is probability of strong drift (mfe_10d - mae_10d > 8%).
|
|
|
"""
|
|
|
import math
|
|
|
|
|
|
reaction = _safe_float(row.get("reaction_day_return"))
|
|
|
if reaction is None or reaction < 0.02:
|
|
|
return 0.0
|
|
|
|
|
|
# Hard gates (same as hand-coded models)
|
|
|
event_type = str(row.get("event_type", "")).lower()
|
|
|
if event_type not in {"earnings_release", "guidance_update", "material_contract",
|
|
|
"other_material_event", "unknown"}:
|
|
|
return 0.0
|
|
|
|
|
|
parse_conf = _safe_float(row.get("parse_confidence_overall"))
|
|
|
if parse_conf is not None and parse_conf < 0.50:
|
|
|
return 0.0
|
|
|
|
|
|
oneoff = _safe_float(row.get("oneoff_penalty"))
|
|
|
if oneoff is not None and oneoff >= 0.40:
|
|
|
return 0.0
|
|
|
|
|
|
# Extract features
|
|
|
cl = _safe_float(row.get("close_location")) or 0.0
|
|
|
vol = _safe_float(row.get("volume_ratio_20d")) or 1.0
|
|
|
gap = _safe_float(row.get("gap_size")) or 0.0
|
|
|
doc = _safe_float(row.get("document_quality_score")) or 0.0
|
|
|
sig = _safe_float(row.get("signal_strength_score")) or 0.0
|
|
|
parse = parse_conf or 0.0
|
|
|
guid = _safe_float(row.get("guidance_direction_score")) or 0.0
|
|
|
oneoff_v = oneoff or 0.0
|
|
|
mcap = _safe_float(row.get("market_cap_proxy")) or 0.0
|
|
|
is_earn = 1.0 if event_type == "earnings_release" else 0.0
|
|
|
is_bull = 1.0 if str(row.get("event_direction", "")).lower() == "bullish" else 0.0
|
|
|
ftb = str(row.get("filing_time_bucket", "")).lower()
|
|
|
is_sd = 1.0 if ftb in ("market_hours", "pre_market") else 0.0
|
|
|
log_mcap = math.log1p(mcap)
|
|
|
rxv = reaction * vol
|
|
|
|
|
|
# Model parameters (trained on midlarge-liquid-long-v1_bucketfix_full_audit/train)
|
|
|
means = [0.07647, 0.66423, 2.77802, 0.05295, 0.59386, 0.33206, 0.57791,
|
|
|
0.47301, 0.49146, 133848323174.3, 0.56379, 0.28462, 0.08561, 24.38727, 0.29600]
|
|
|
scales = [0.07192, 0.26650, 1.70445, 0.06393, 0.12190, 0.28695, 0.13448,
|
|
|
0.36046, 0.36866, 423835964594.9, 0.49591, 0.45124, 0.27978, 1.35218, 0.64006]
|
|
|
coefs = [1.01880, -0.23567, -0.33227, -0.07947, 0.05002, 0.13124, 0.04875,
|
|
|
0.05731, -0.18163, 0.23227, -0.15206, -0.06086, -0.00290, -0.45036, 0.29914]
|
|
|
intercept = 0.44324
|
|
|
|
|
|
raw_feats = [reaction, cl, vol, gap, doc, sig, parse, guid, oneoff_v, mcap,
|
|
|
is_earn, is_bull, is_sd, log_mcap, rxv]
|
|
|
|
|
|
# Standardize and compute logit
|
|
|
z = intercept
|
|
|
for i in range(len(raw_feats)):
|
|
|
scaled = (raw_feats[i] - means[i]) / max(scales[i], 1e-10)
|
|
|
z += scaled * coefs[i]
|
|
|
|
|
|
prob = 1.0 / (1.0 + math.exp(-z))
|
|
|
return _clamp(prob)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v2(row: dict[str, Any]) -> float:
|
|
|
"""ML scoring v2: numeric logistic model trained on canonical v17-era features.
|
|
|
|
|
|
Target:
|
|
|
strong_drift_10d := (mfe_10d - mae_10d) > 8%
|
|
|
|
|
|
Notes:
|
|
|
- Uses only snapshot features already materialized in the canonical dataset.
|
|
|
- This is a ranking/classification scorer, not a direct 14d-return regressor.
|
|
|
- Categorical event fields were intentionally excluded because they added
|
|
|
little incremental signal while making runtime inference heavier.
|
|
|
"""
|
|
|
return _compute_numeric_logistic_score(
|
|
|
row,
|
|
|
features=_RETURN_MAX_LONG_ML_V2_FEATURES,
|
|
|
medians=_RETURN_MAX_LONG_ML_V2_MEDIANS,
|
|
|
means=_RETURN_MAX_LONG_ML_V2_MEANS,
|
|
|
scales=_RETURN_MAX_LONG_ML_V2_SCALES,
|
|
|
coefficients=_RETURN_MAX_LONG_ML_V2_COEFFICIENTS,
|
|
|
intercept=_RETURN_MAX_LONG_ML_V2_INTERCEPT,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v2_h1(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v1: keep v13e eligibility, use ML for re-ranking.
|
|
|
|
|
|
This is the least invasive hybrid:
|
|
|
- v13e remains the hard eligibility gate
|
|
|
- among already-eligible rows, ML probability becomes the rank score
|
|
|
"""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
return compute_return_max_long_score_ml_v2(row)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v2_h2(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v2: weighted blend of v13e and ML probability."""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
ml_score = compute_return_max_long_score_ml_v2(row)
|
|
|
return _clamp(base_score * 0.65 + ml_score * 0.35)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v2_h3(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v3: v13e base plus stronger ML rank bonus."""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
ml_score = compute_return_max_long_score_ml_v2(row)
|
|
|
return _clamp(base_score + 0.30 * (ml_score - 0.50))
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v3(row: dict[str, Any]) -> float:
|
|
|
"""ML scoring v3: numeric logistic model with SUE + peer-relative features."""
|
|
|
return _compute_numeric_logistic_score(
|
|
|
row,
|
|
|
features=_RETURN_MAX_LONG_ML_V3_FEATURES,
|
|
|
medians=_RETURN_MAX_LONG_ML_V3_MEDIANS,
|
|
|
means=_RETURN_MAX_LONG_ML_V3_MEANS,
|
|
|
scales=_RETURN_MAX_LONG_ML_V3_SCALES,
|
|
|
coefficients=_RETURN_MAX_LONG_ML_V3_COEFFICIENTS,
|
|
|
intercept=_RETURN_MAX_LONG_ML_V3_INTERCEPT,
|
|
|
)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v3_h1(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v1: keep v13e eligibility, use ML v3 for re-ranking."""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
return compute_return_max_long_score_ml_v3(row)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v3_h2(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v2: weighted blend of v13e and ML v3 probability."""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
ml_score = compute_return_max_long_score_ml_v3(row)
|
|
|
return _clamp(base_score * 0.65 + ml_score * 0.35)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_ml_v3_h3(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid v3: v13e base plus stronger ML v3 rank bonus."""
|
|
|
base_score = compute_return_max_long_score_v13e(row)
|
|
|
if base_score <= 0.0:
|
|
|
return 0.0
|
|
|
ml_score = compute_return_max_long_score_ml_v3(row)
|
|
|
return _clamp(base_score + 0.30 * (ml_score - 0.50))
|
|
|
|
|
|
|
|
|
_LGBM_V1_STATE: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
def _get_lgbm_v1() -> dict[str, Any] | None:
|
|
|
"""Lazy-load LightGBM booster + feature manifest. Returns None on failure."""
|
|
|
if _LGBM_V1_STATE:
|
|
|
return _LGBM_V1_STATE if _LGBM_V1_STATE.get("ok") else None
|
|
|
import json as _json
|
|
|
from pathlib import Path as _Path
|
|
|
repo_root = _Path(__file__).resolve().parents[2]
|
|
|
model_path = repo_root / "data/ml_models/lgbm_v1_pead_long.txt"
|
|
|
manifest_path = repo_root / "data/ml_models/lgbm_v1_pead_long_manifest.json"
|
|
|
try:
|
|
|
import lightgbm as _lgbm
|
|
|
booster = _lgbm.Booster(model_file=str(model_path))
|
|
|
manifest = _json.loads(manifest_path.read_text())
|
|
|
cat_codes = {
|
|
|
c: {v: i for i, v in enumerate(manifest["categorical_vocab"][c])}
|
|
|
for c in manifest["categorical_features"]
|
|
|
}
|
|
|
_LGBM_V1_STATE.update({
|
|
|
"ok": True,
|
|
|
"booster": booster,
|
|
|
"numeric": manifest["numeric_features"],
|
|
|
"categorical": manifest["categorical_features"],
|
|
|
"cat_codes": cat_codes,
|
|
|
})
|
|
|
logger.info("Loaded LGBM v1 PEAD long scorer from %s", model_path)
|
|
|
except Exception as exc:
|
|
|
_LGBM_V1_STATE["ok"] = False
|
|
|
logger.warning("Failed to load LGBM v1 scorer: %s", exc)
|
|
|
return None
|
|
|
return _LGBM_V1_STATE
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_lgbm_v1(row: dict[str, Any]) -> float:
|
|
|
"""LightGBM PEAD long scorer trained on ftb_fix_v2 snapshot.
|
|
|
|
|
|
Target: strong_drift_10d := (mfe_10d - mae_10d) > 8%.
|
|
|
Returns predicted probability in [0, 1]; falls back to v13e if model
|
|
|
unavailable.
|
|
|
"""
|
|
|
state = _get_lgbm_v1()
|
|
|
if state is None:
|
|
|
return compute_return_max_long_score_v13e(row)
|
|
|
nums = []
|
|
|
for feat in state["numeric"]:
|
|
|
val = row.get(feat)
|
|
|
try:
|
|
|
f = float(val) if val is not None else float("nan")
|
|
|
except (TypeError, ValueError):
|
|
|
f = float("nan")
|
|
|
nums.append(f)
|
|
|
cats = []
|
|
|
for feat in state["categorical"]:
|
|
|
val = row.get(feat)
|
|
|
if val is None:
|
|
|
cats.append(-1)
|
|
|
continue
|
|
|
cats.append(state["cat_codes"][feat].get(str(val), -1))
|
|
|
import numpy as _np
|
|
|
x = _np.array([nums + cats], dtype=float)
|
|
|
prob = float(state["booster"].predict(x)[0])
|
|
|
return _clamp(prob)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_lgbm_v1_h1(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid: v13e eligibility gate + LGBM re-rank."""
|
|
|
base = compute_return_max_long_score_v13e(row)
|
|
|
if base <= 0.0:
|
|
|
return 0.0
|
|
|
return compute_return_max_long_score_lgbm_v1(row)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_lgbm_v1_h2(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid: weighted blend (v13e 0.65 + LGBM 0.35)."""
|
|
|
base = compute_return_max_long_score_v13e(row)
|
|
|
if base <= 0.0:
|
|
|
return 0.0
|
|
|
ml = compute_return_max_long_score_lgbm_v1(row)
|
|
|
return _clamp(base * 0.65 + ml * 0.35)
|
|
|
|
|
|
|
|
|
def compute_return_max_long_score_lgbm_v1_h3(row: dict[str, Any]) -> float:
|
|
|
"""Hybrid: v13e base + LGBM rank bonus (~+/-0.15)."""
|
|
|
base = compute_return_max_long_score_v13e(row)
|
|
|
if base <= 0.0:
|
|
|
return 0.0
|
|
|
ml = compute_return_max_long_score_lgbm_v1(row)
|
|
|
return _clamp(base + 0.30 * (ml - 0.50))
|
|
|
|
|
|
|
|
|
def compute_return_max_longshort_v1(row: dict[str, Any]) -> float:
|
|
|
"""Combined long + short PEAD scoring.
|
|
|
|
|
|
Applies long scoring (v13e) for bullish reactions and short scoring (v1)
|
|
|
for bearish reactions. Returns the higher of the two, allowing both
|
|
|
long and short candidates to pass through to engine matching.
|
|
|
"""
|
|
|
long_score = compute_return_max_long_score_v13e(row)
|
|
|
if long_score > 0:
|
|
|
return long_score
|
|
|
return compute_return_max_short_score_v1(row)
|
|
|
|
|
|
|
|
|
def compute_return_max_longshort_ml_v1(row: dict[str, Any]) -> float:
|
|
|
"""Combined ML long + short (v1) scoring."""
|
|
|
long_score = compute_return_max_long_score_ml_v1(row)
|
|
|
if long_score > 0:
|
|
|
return long_score
|
|
|
return compute_return_max_short_score_v1(row)
|
|
|
|
|
|
|
|
|
def compute_return_max_longshort_v1b(row: dict[str, Any]) -> float:
|
|
|
"""Combined long (v17b contrarian) + short (v1) scoring."""
|
|
|
long_score = compute_return_max_long_score_v17b(row)
|
|
|
if long_score > 0:
|
|
|
return long_score
|
|
|
return compute_return_max_short_score_v1(row)
|
|
|
|
|
|
|
|
|
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,
|
|
|
)
|