You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
397 lines
13 KiB
Python
397 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import pyarrow.parquet as pq
|
|
from lightgbm import LGBMClassifier, LGBMRegressor
|
|
from sklearn.compose import ColumnTransformer
|
|
from sklearn.impute import SimpleImputer
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.metrics import roc_auc_score
|
|
from sklearn.pipeline import Pipeline
|
|
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
|
|
|
|
SNAPSHOT_ROOT = Path("data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical")
|
|
NUMERIC_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_lag_4_pct",
|
|
"sue_lag_5_pct",
|
|
"sue_lag_6_pct",
|
|
"sue_lag_7_pct",
|
|
"sue_lag_8_pct",
|
|
"sue_lag_9_pct",
|
|
"sue_lag_10_pct",
|
|
"sue_lag_11_pct",
|
|
"sue_lag_12_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",
|
|
]
|
|
CAT_FEATURES = ["event_type", "event_direction", "guidance_status", "sector"]
|
|
ALL_COLUMNS = NUMERIC_FEATURES + CAT_FEATURES + [
|
|
"reported_eps",
|
|
"estimated_eps",
|
|
"fwd_return_10d",
|
|
"fwd_return_20d",
|
|
"mfe_10d",
|
|
"mae_10d",
|
|
]
|
|
|
|
|
|
def _availability_report(columns: set[str]) -> dict[str, dict[str, object]]:
|
|
requested = {
|
|
"v13e_scoring_core": {
|
|
"required": [
|
|
"reaction_day_return",
|
|
"gap_size",
|
|
"close_location",
|
|
"volume_ratio_20d",
|
|
"document_quality_score",
|
|
"parse_confidence_overall",
|
|
"oneoff_penalty",
|
|
],
|
|
"nice_to_have": [
|
|
"market_cap_proxy",
|
|
"avg_dollar_volume_20d",
|
|
],
|
|
},
|
|
"historical_12q_sue_vector": {
|
|
"required": [],
|
|
"nice_to_have": [
|
|
"reported_eps",
|
|
"estimated_eps",
|
|
"earnings_surprise_pct",
|
|
"sue_lag_1_pct",
|
|
"sue_lag_2_pct",
|
|
"sue_lag_3_pct",
|
|
"sue_lag_4_pct",
|
|
"sue_lag_5_pct",
|
|
"sue_lag_6_pct",
|
|
"sue_lag_7_pct",
|
|
"sue_lag_8_pct",
|
|
"sue_lag_9_pct",
|
|
"sue_lag_10_pct",
|
|
"sue_lag_11_pct",
|
|
"sue_lag_12_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",
|
|
],
|
|
},
|
|
"transcript_sentiment_surprise": {
|
|
"required": [],
|
|
"nice_to_have": ["lm_positive_pct", "lm_negative_pct", "lm_net_sentiment"],
|
|
},
|
|
"three_day_early_reaction": {
|
|
"required": [],
|
|
"nice_to_have": [],
|
|
},
|
|
"insider_form4_persistence": {
|
|
"required": [],
|
|
"nice_to_have": [],
|
|
},
|
|
"peer_relative_surprise": {
|
|
"required": [],
|
|
"nice_to_have": [
|
|
"sector",
|
|
"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",
|
|
],
|
|
},
|
|
"options_skew_iv_term_structure": {
|
|
"required": [],
|
|
"nice_to_have": [],
|
|
},
|
|
"technical_macro_context": {
|
|
"required": [
|
|
"pre_event_entropy_60d",
|
|
"pre_event_hurst_60d",
|
|
"pre_event_gravitational_pull",
|
|
"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",
|
|
],
|
|
"nice_to_have": [],
|
|
},
|
|
}
|
|
report: dict[str, dict[str, object]] = {}
|
|
for name, spec in requested.items():
|
|
present = [c for c in spec["required"] + spec["nice_to_have"] if c in columns]
|
|
missing_required = [c for c in spec["required"] if c not in columns]
|
|
missing_optional = [c for c in spec["nice_to_have"] if c not in columns]
|
|
if spec["required"] and not missing_required:
|
|
status = "available"
|
|
elif present:
|
|
status = "partial"
|
|
else:
|
|
status = "missing"
|
|
report[name] = {
|
|
"status": status,
|
|
"present": present,
|
|
"missing_required": missing_required,
|
|
"missing_optional": missing_optional,
|
|
}
|
|
return report
|
|
|
|
|
|
def _load_split(snapshot_root: Path, split: str) -> pd.DataFrame:
|
|
parquet_path = snapshot_root / f"{split}.parquet"
|
|
schema = pq.read_schema(parquet_path)
|
|
present = [column for column in ALL_COLUMNS if column in schema.names]
|
|
df = pq.read_table(parquet_path, columns=present).to_pandas()
|
|
for column in ALL_COLUMNS:
|
|
if column not in df.columns:
|
|
df[column] = pd.NA
|
|
df["target_reg_10d"] = df["fwd_return_10d"]
|
|
df["target_cls_strong_drift_10d"] = ((df["mfe_10d"] - df["mae_10d"]) > 0.08).astype(int)
|
|
for col in CAT_FEATURES:
|
|
df[col] = df[col].fillna("missing").astype("category")
|
|
for col in NUMERIC_FEATURES:
|
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
return df
|
|
|
|
|
|
def _bucket_summary(pred: pd.Series, target: pd.Series) -> dict[str, float | None]:
|
|
out = pd.DataFrame({"pred": pred, "target": target}).dropna()
|
|
if out.empty:
|
|
return {
|
|
"mean_target": None,
|
|
"top_quintile_mean": None,
|
|
"bottom_quintile_mean": None,
|
|
"spread": None,
|
|
"corr": None,
|
|
}
|
|
out["bucket"] = pd.qcut(out["pred"], 5, labels=False, duplicates="drop")
|
|
top = out[out["bucket"] == out["bucket"].max()]["target"].mean()
|
|
bottom = out[out["bucket"] == out["bucket"].min()]["target"].mean()
|
|
corr = out["pred"].corr(out["target"])
|
|
return {
|
|
"mean_target": round(float(out["target"].mean()), 4),
|
|
"top_quintile_mean": round(float(top), 4),
|
|
"bottom_quintile_mean": round(float(bottom), 4),
|
|
"spread": round(float(top - bottom), 4),
|
|
"corr": None if pd.isna(corr) else round(float(corr), 4),
|
|
}
|
|
|
|
|
|
def _top_decile_summary(pred: pd.Series, target: pd.Series, extra: pd.Series | None = None) -> dict[str, float | int | None]:
|
|
out = pd.DataFrame({"pred": pred, "target": target}).dropna()
|
|
if extra is not None:
|
|
out["extra"] = extra.loc[out.index]
|
|
if out.empty:
|
|
return {"count": 0, "hit_rate": None, "extra_mean": None}
|
|
threshold = out["pred"].quantile(0.9)
|
|
top = out[out["pred"] >= threshold]
|
|
payload: dict[str, float | int | None] = {
|
|
"count": int(len(top)),
|
|
"hit_rate": round(float(top["target"].mean()), 4),
|
|
}
|
|
if "extra" in top:
|
|
payload["extra_mean"] = round(float(top["extra"].mean()), 4)
|
|
return payload
|
|
|
|
|
|
def run_probe(snapshot_root: Path) -> dict[str, object]:
|
|
train = _load_split(snapshot_root, "train")
|
|
valid = _load_split(snapshot_root, "valid")
|
|
test = _load_split(snapshot_root, "test")
|
|
|
|
availability = _availability_report(set(train.columns))
|
|
|
|
num_pipe = Pipeline(
|
|
[
|
|
("impute", SimpleImputer(strategy="median")),
|
|
("scale", StandardScaler()),
|
|
]
|
|
)
|
|
cat_pipe = Pipeline(
|
|
[
|
|
("impute", SimpleImputer(strategy="most_frequent")),
|
|
("onehot", OneHotEncoder(handle_unknown="ignore")),
|
|
]
|
|
)
|
|
prep = ColumnTransformer(
|
|
[
|
|
("num", num_pipe, NUMERIC_FEATURES),
|
|
("cat", cat_pipe, CAT_FEATURES),
|
|
]
|
|
)
|
|
logit = Pipeline(
|
|
[
|
|
("prep", prep),
|
|
("model", LogisticRegression(max_iter=2000, class_weight="balanced")),
|
|
]
|
|
)
|
|
logit.fit(train[NUMERIC_FEATURES + CAT_FEATURES], train["target_cls_strong_drift_10d"])
|
|
|
|
lgbm_cls = LGBMClassifier(
|
|
n_estimators=400,
|
|
learning_rate=0.03,
|
|
num_leaves=31,
|
|
min_child_samples=50,
|
|
subsample=0.8,
|
|
colsample_bytree=0.8,
|
|
objective="binary",
|
|
random_state=42,
|
|
verbosity=-1,
|
|
)
|
|
lgbm_cls.fit(
|
|
train[NUMERIC_FEATURES + CAT_FEATURES],
|
|
train["target_cls_strong_drift_10d"],
|
|
categorical_feature=CAT_FEATURES,
|
|
)
|
|
|
|
lgbm_reg = LGBMRegressor(
|
|
n_estimators=300,
|
|
learning_rate=0.05,
|
|
num_leaves=31,
|
|
min_child_samples=40,
|
|
subsample=0.8,
|
|
colsample_bytree=0.8,
|
|
objective="regression",
|
|
random_state=42,
|
|
verbosity=-1,
|
|
)
|
|
lgbm_reg.fit(
|
|
train[NUMERIC_FEATURES + CAT_FEATURES],
|
|
train["target_reg_10d"],
|
|
categorical_feature=CAT_FEATURES,
|
|
)
|
|
|
|
report: dict[str, object] = {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"snapshot_root": str(snapshot_root),
|
|
"target_notes": {
|
|
"classification": "strong_drift_10d := (mfe_10d - mae_10d) > 8%",
|
|
"regression": "fwd_return_10d used as proxy for direct return prediction; 14d label is not materialized yet",
|
|
},
|
|
"feature_availability": availability,
|
|
"models": {},
|
|
}
|
|
|
|
for split_name, df in [("valid", valid), ("test", test)]:
|
|
X = df[NUMERIC_FEATURES + CAT_FEATURES]
|
|
cls_target = df["target_cls_strong_drift_10d"]
|
|
reg_target = df["target_reg_10d"]
|
|
|
|
logit_prob = pd.Series(logit.predict_proba(X)[:, 1], index=df.index)
|
|
lgbm_prob = pd.Series(lgbm_cls.predict_proba(X)[:, 1], index=df.index)
|
|
reg_pred = pd.Series(lgbm_reg.predict(X), index=df.index)
|
|
|
|
report["models"].setdefault("logistic_strong_drift_10d", {})[split_name] = {
|
|
"auc": round(float(roc_auc_score(cls_target, logit_prob)), 4),
|
|
"bucket_summary": _bucket_summary(logit_prob, cls_target),
|
|
"top_decile": _top_decile_summary(logit_prob, cls_target, reg_target),
|
|
}
|
|
report["models"].setdefault("lightgbm_strong_drift_10d", {})[split_name] = {
|
|
"auc": round(float(roc_auc_score(cls_target, lgbm_prob)), 4),
|
|
"bucket_summary": _bucket_summary(lgbm_prob, cls_target),
|
|
"top_decile": _top_decile_summary(lgbm_prob, cls_target, reg_target),
|
|
}
|
|
report["models"].setdefault("lightgbm_return_10d_regression", {})[split_name] = {
|
|
"bucket_summary": _bucket_summary(reg_pred, reg_target),
|
|
"top_decile": _top_decile_summary(reg_pred, cls_target, reg_target),
|
|
}
|
|
|
|
importances = dict(
|
|
sorted(
|
|
zip(NUMERIC_FEATURES + CAT_FEATURES, lgbm_cls.feature_importances_.tolist(), strict=False),
|
|
key=lambda x: x[1],
|
|
reverse=True,
|
|
)[:12]
|
|
)
|
|
report["models"]["lightgbm_strong_drift_10d"]["top_feature_importances"] = importances
|
|
return report
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Feasibility probe for full-ML predictor replacement.")
|
|
parser.add_argument(
|
|
"--snapshot-root",
|
|
default=str(SNAPSHOT_ROOT),
|
|
help="Canonical snapshot root containing train/valid/test parquet files.",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="runs/ml_predictor_feasibility_20260401.json",
|
|
help="Path to write JSON report.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
snapshot_root = Path(args.snapshot_root)
|
|
report = run_probe(snapshot_root)
|
|
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(json.dumps(report, indent=2))
|
|
print(output_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|