#!/usr/bin/env python3 """Train LogReg for ml_v3 scorer on ftb_fix_v2 snapshot. Emits Python tuple literals for FEATURES, MEDIANS, MEANS, SCALES, COEFFICIENTS plus INTERCEPT - ready to paste into libs/backtest/scoring.py. """ from __future__ import annotations import argparse import json from pathlib import Path import numpy as np import pandas as pd import pyarrow.parquet as pq from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score CANDIDATE_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", # additional features available in ftb_fix_v2 we want to consider: "pre_event_short_ratio", "pre_event_sector_momentum_20d", "prior_catalyst_count_60d", "prior_catalyst_type_diversity_60d", "price_vs_sma20", "pre_event_momentum_20d", ] TARGET_AUX = ["mfe_10d", "mae_10d", "fwd_return_10d"] def _load(snapshot_root: Path, split: str, present_cols: list[str]) -> pd.DataFrame: path = snapshot_root / f"{split}.parquet" schema = pq.read_schema(path) cols = [c for c in present_cols + TARGET_AUX if c in schema.names] df = pq.read_table(path, columns=cols).to_pandas() for c in present_cols: if c not in df.columns: df[c] = np.nan df[c] = pd.to_numeric(df[c], errors="coerce") df["target"] = ((df["mfe_10d"] - df["mae_10d"]) > 0.08).astype(int) return df def _format_tuple(values, name, width=4) -> str: lines = [f"{name} = ("] for v in values: if isinstance(v, str): lines.append(f' "{v}",') else: lines.append(f" {v!r},") lines.append(")") return "\n".join(lines) def main(): ap = argparse.ArgumentParser() ap.add_argument("--snapshot-root", default="data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2") ap.add_argument("--output", default="runs/ml_v3_logreg_constants.py") ap.add_argument("--report", default="runs/ml_v3_logreg_metrics.json") args = ap.parse_args() root = Path(args.snapshot_root) schema = pq.read_schema(root / "train.parquet") available = [c for c in CANDIDATE_FEATURES if c in schema.names] train = _load(root, "train", available) valid = _load(root, "valid", available) test = _load(root, "test", available) # filter to features with at least 1 non-null value in train AND non-zero variance coverage = train[available].notna().mean() variance = train[available].var(skipna=True) keep = [ c for c in available if coverage.get(c, 0) > 0.05 and (variance.get(c, 0) or 0) > 1e-12 ] print(f"Available: {len(available)}, kept: {len(keep)}") print(f"Dropped (low coverage / zero variance): {sorted(set(available) - set(keep))}") medians = train[keep].median(skipna=True) train_filled = train[keep].fillna(medians) means = train_filled.mean() stds = train_filled.std(ddof=0).replace(0, 1.0) Xtr = (train_filled - means) / stds ytr = train["target"].values model = LogisticRegression(max_iter=5000, class_weight=None, C=1.0) model.fit(Xtr, ytr) coefs = model.coef_[0] intercept = float(model.intercept_[0]) # eval def _pred(df: pd.DataFrame) -> np.ndarray: Xf = (df[keep].fillna(medians) - means) / stds return model.predict_proba(Xf)[:, 1] metrics = {} for name, df in [("train", train), ("valid", valid), ("test", test)]: prob = _pred(df) try: auc = roc_auc_score(df["target"], prob) except ValueError: auc = None prob_series = pd.Series(prob, index=df.index) target_series = df["target"] try: buckets = pd.qcut(prob_series, 5, labels=False, duplicates="drop") top = target_series[buckets == buckets.max()].mean() bot = target_series[buckets == buckets.min()].mean() spread = float(top - bot) except Exception: top = bot = spread = None metrics[name] = { "auc": float(auc) if auc is not None else None, "top_quintile": float(top) if top is not None else None, "bottom_quintile": float(bot) if bot is not None else None, "spread": spread, "mean_pred": float(prob.mean()), "std_pred": float(prob.std()), "p10": float(np.percentile(prob, 10)), "p50": float(np.percentile(prob, 50)), "p90": float(np.percentile(prob, 90)), } print(f"{name}: auc={metrics[name]['auc']} spread={spread} pred_std={prob.std():.4f} pred_mean={prob.mean():.4f}") # write outputs out_py = Path(args.output) out_py.parent.mkdir(parents=True, exist_ok=True) out_py.write_text( '"""Auto-generated by scripts/train_ml_v3_logreg.py - paste into scoring.py"""\n\n' + _format_tuple(keep, "_RETURN_MAX_LONG_ML_V3_FEATURES") + "\n" + _format_tuple([float(medians[c]) for c in keep], "_RETURN_MAX_LONG_ML_V3_MEDIANS") + "\n" + _format_tuple([float(means[c]) for c in keep], "_RETURN_MAX_LONG_ML_V3_MEANS") + "\n" + _format_tuple([float(stds[c]) for c in keep], "_RETURN_MAX_LONG_ML_V3_SCALES") + "\n" + _format_tuple([float(c) for c in coefs], "_RETURN_MAX_LONG_ML_V3_COEFFICIENTS") + "\n" + f"_RETURN_MAX_LONG_ML_V3_INTERCEPT = {intercept!r}\n" ) Path(args.report).write_text(json.dumps({ "metrics": metrics, "n_features": len(keep), "features": keep, "snapshot": str(root), }, indent=2)) print(f"\nWrote {out_py}") print(f"Wrote {args.report}") if __name__ == "__main__": main()