Spaces:
Running
Running
Khanna, Videh Rakesh Rakesh
research: feature/selection A/B tools (meta-labeling, sector, delivery, rolling OOS)
87f5964 | #!/usr/bin/env python3 | |
| """research/selection_rolling_oos.py — is the ML top-N SELECTION edge robust across MANY | |
| out-of-sample windows, or a one-window fluke? | |
| research/ml_selection_backtest.py --six-filter showed the top-N BULLISH basket beats the market | |
| on ONE 6-month holdout. This rolls the cutoff across several consecutive OOS windows and reports | |
| the basket's edge / Sharpe / win-rate per window, so we can see if the edge is consistent. | |
| For each window: train a self-contained base model (HGB direction classifier + up_q50 quantile | |
| regressor) on all rows BEFORE the window, then on each decision day in the window buy the top-N | |
| BULLISH stocks ranked by up_q50 and measure the equal-weight basket's 3-day net return vs the | |
| equal-weight market. | |
| Research only; reads training_data_extra.csv; never touches the production model. | |
| Usage: | |
| python research/selection_rolling_oos.py # top-5, 6×2-month windows | |
| python research/selection_rolling_oos.py --top 5 --windows 6 --test-months 2 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| import os | |
| import sys | |
| import warnings | |
| import numpy as np | |
| import pandas as pd | |
| warnings.filterwarnings("ignore") | |
| _PROJ_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| if _PROJ_ROOT not in sys.path: | |
| sys.path.insert(0, _PROJ_ROOT) | |
| from ml_predictor.features import FEATURE_COLUMNS # noqa: E402 | |
| # Delivery feature columns (research-only; the prod feature pipeline does NOT include these — | |
| # the delivery %% experiment was validated NOT robust across rolling windows and not promoted). | |
| DELIVERY_FEATURE_COLS = ["deliv_per", "deliv_z20", "deliv_chg5"] | |
| _CSV = os.path.join(_PROJ_ROOT, "ml_predictor", "training_data_extra.csv") | |
| _CSV_DELIV = os.path.join(_PROJ_ROOT, "ml_predictor", "training_data_delivery.csv") | |
| COST = 0.30 | |
| TRADING_DAYS = 252 | |
| HOLD = 3 | |
| def _sharpe(rets): | |
| a = np.asarray([x for x in rets if not np.isnan(x)], dtype=float) | |
| if a.size < 2 or a.std(ddof=1) <= 1e-9: | |
| return 0.0 | |
| return float(a.mean() / a.std(ddof=1) * np.sqrt(TRADING_DAYS / HOLD)) | |
| def _eval_window(base_dir, up50, feats, te, top_n): | |
| """Per-day top-N basket net return vs market over one OOS window.""" | |
| Xte = te[feats].to_numpy(float) | |
| te = te.assign(_dir=base_dir.predict(Xte), _up50=up50.predict(Xte), | |
| _net=te["ret_3D"].to_numpy(float) - COST) | |
| basket, market = [], [] | |
| for _, day in te.groupby("date"): | |
| market.append(float(day["_net"].mean())) | |
| bull = day[day["_dir"] == "BULLISH"] | |
| if len(bull) < top_n: | |
| basket.append(np.nan); continue | |
| pick = bull.sort_values("_up50", ascending=False).head(top_n) | |
| basket.append(float(pick["_net"].mean())) | |
| b = np.array([x for x in basket if not np.isnan(x)]) | |
| m = np.array(market) | |
| edge = np.array([bb - mk for bb, mk in zip(basket, market) if not np.isnan(bb)]) | |
| return { | |
| "days": len(b), | |
| "basket": b.mean() if len(b) else float("nan"), | |
| "market": m.mean() if len(m) else float("nan"), | |
| "edge": edge.mean() if len(edge) else float("nan"), | |
| "sharpe": _sharpe(basket), | |
| "win_days": float((b > 0).mean()) if len(b) else float("nan"), | |
| }, basket, market | |
| def run(top_n: int, windows: int, test_months: int, min_train_months: int): | |
| from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor | |
| df = pd.read_csv(_CSV) | |
| df["date"] = pd.to_datetime(df["date"]) | |
| feats = [c for c in FEATURE_COLUMNS if c in df.columns] | |
| df = df.dropna(subset=feats + ["ret_3D", "up_3D", "dir_3D", "date"]).reset_index(drop=True) | |
| dmax = df["date"].max() | |
| dmin = df["date"].min() | |
| # Build `windows` consecutive test slices ending at dmax, each test_months long. | |
| edges = [dmax - pd.DateOffset(months=test_months * k) for k in range(windows + 1)][::-1] | |
| slices = list(zip(edges[:-1], edges[1:])) # (test_start, test_end) | |
| print(f" Data {dmin.date()} → {dmax.date()} · top-{top_n} · {windows}×{test_months}-mo OOS windows · cost {COST}%") | |
| all_basket, all_market = [], [] | |
| rows = [] | |
| for wi, (ts, tend) in enumerate(slices, 1): | |
| tr = df[df["date"] < ts] | |
| te = df[(df["date"] >= ts) & (df["date"] < tend)] | |
| if (ts - dmin).days < min_train_months * 30 or len(te) < 200: | |
| print(f" window {wi} {ts.date()}→{tend.date()}: skipped (train too short / OOS too small)") | |
| continue | |
| Xtr = tr[feats].to_numpy(float) | |
| bd = HistGradientBoostingClassifier(max_iter=250, learning_rate=0.06, | |
| class_weight="balanced", random_state=0) | |
| bd.fit(Xtr, tr["dir_3D"].to_numpy()) | |
| u5 = HistGradientBoostingRegressor(loss="quantile", quantile=0.5, max_iter=250, | |
| learning_rate=0.06, random_state=0) | |
| u5.fit(Xtr, tr["up_3D"].to_numpy(float)) | |
| s, basket, market = _eval_window(bd, u5, feats, te, top_n) | |
| all_basket += basket; all_market += market | |
| rows.append({"win": wi, "start": ts.date(), "end": tend.date(), **s}) | |
| print(f" window {wi} {ts.date()}→{tend.date()}: trained on {len(tr):,} rows, {s['days']} decision days") | |
| print("\n" + "=" * 88) | |
| print(f" ROLLING OOS SELECTION EDGE — top-{top_n} BULLISH basket, 3-day hold, net of {COST}%") | |
| print("=" * 88) | |
| print(f" {'Window':<7}{'Period':<24}{'Days':>6}{'Basket%':>9}{'Market%':>9}{'Edge':>8}{'Sharpe':>8}{'WinDay':>8}") | |
| print(" " + "-" * 78) | |
| pos = 0 | |
| for r in rows: | |
| flag = " ⟵" if r["edge"] > 0 and r["sharpe"] > 0.5 else "" | |
| if r["edge"] > 0: | |
| pos += 1 | |
| print(f" {r['win']:<7}{str(r['start'])+'→'+str(r['end']):<24}{r['days']:>6}" | |
| f"{r['basket']:>+9.2f}{r['market']:>+9.2f}{r['edge']:>+8.2f}{r['sharpe']:>8.2f}{r['win_days']:>7.0%}{flag}") | |
| # Pooled across all windows | |
| ps = _sharpe(all_basket) | |
| bb = np.array([x for x in all_basket if not np.isnan(x)]) | |
| edge_all = np.array([b - m for b, m in zip(all_basket, all_market) if not np.isnan(b)]) | |
| print(" " + "-" * 78) | |
| print(f" {'POOLED':<7}{'all windows':<24}{len(bb):>6}{bb.mean():>+9.2f}" | |
| f"{'':<9}{edge_all.mean():>+8.2f}{ps:>8.2f}{(bb>0).mean():>7.0%}") | |
| print(f"\n Windows with positive edge: {pos}/{len(rows)} · pooled Sharpe {ps:.2f} · " | |
| f"pooled edge {edge_all.mean():+.2f}%/trade") | |
| if pos >= math.ceil(0.7 * len(rows)) and ps > 0.5: | |
| print(" → ROBUST: the selection edge holds across most windows (not a one-window fluke).") | |
| else: | |
| print(" → FRAGILE: the edge is inconsistent across windows — treat with caution.") | |
| def run_dual(top_n: int, windows: int, test_months: int, min_train_months: int): | |
| """Per rolling window, train BASE (current features) AND BASE+DELIVERY, compare the edge/Sharpe | |
| delta — so we can see if delivery %% helps CONSISTENTLY, not just on one window.""" | |
| from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor | |
| df = pd.read_csv(_CSV_DELIV) | |
| df["date"] = pd.to_datetime(df["date"]) | |
| base_feats = [c for c in FEATURE_COLUMNS if c in df.columns and not c.startswith("deliv")] | |
| deliv_feats = base_feats + [c for c in DELIVERY_FEATURE_COLS if c in df.columns] | |
| df = df.dropna(subset=base_feats + ["ret_3D", "up_3D", "dir_3D", "date"]).reset_index(drop=True) | |
| dmax, dmin = df["date"].max(), df["date"].min() | |
| edges = [dmax - pd.DateOffset(months=test_months * k) for k in range(windows + 1)][::-1] | |
| slices = list(zip(edges[:-1], edges[1:])) | |
| print(f" BASE vs BASE+DELIVERY · top-{top_n} · {windows}×{test_months}-mo OOS windows · " | |
| f"{len(deliv_feats)-len(base_feats)} delivery feats") | |
| def _fit(feats, tr): | |
| bd = HistGradientBoostingClassifier(max_iter=250, learning_rate=0.06, | |
| class_weight="balanced", random_state=0) | |
| bd.fit(tr[feats].to_numpy(float), tr["dir_3D"].to_numpy()) | |
| u5 = HistGradientBoostingRegressor(loss="quantile", quantile=0.5, max_iter=250, | |
| learning_rate=0.06, random_state=0) | |
| u5.fit(tr[feats].to_numpy(float), tr["up_3D"].to_numpy(float)) | |
| return bd, u5 | |
| rows = [] | |
| for wi, (ts, tend) in enumerate(slices, 1): | |
| tr = df[df["date"] < ts] | |
| te = df[(df["date"] >= ts) & (df["date"] < tend)] | |
| if (ts - dmin).days < min_train_months * 30 or len(te) < 200: | |
| continue | |
| bd0, u0 = _fit(base_feats, tr) | |
| s0, _, _ = _eval_window(bd0, u0, base_feats, te, top_n) | |
| bd1, u1 = _fit(deliv_feats, tr) | |
| s1, _, _ = _eval_window(bd1, u1, deliv_feats, te, top_n) | |
| rows.append({"win": wi, "start": ts.date(), "end": tend.date(), | |
| "e0": s0["edge"], "sh0": s0["sharpe"], "e1": s1["edge"], "sh1": s1["sharpe"]}) | |
| print(f" window {wi} {ts.date()}→{tend.date()}: trained on {len(tr):,} rows") | |
| print("\n" + "=" * 90) | |
| print(f" ROLLING BASE vs +DELIVERY — top-{top_n} selection edge & Sharpe per OOS window") | |
| print("=" * 90) | |
| print(f" {'Window':<24}{'Base edge':>10}{'+Deliv edge':>12}{'Δedge':>8}" | |
| f"{'Base Shp':>10}{'+Deliv Shp':>12}{'ΔShp':>8}") | |
| print(" " + "-" * 84) | |
| de_pos = sh_pos = 0 | |
| for r in rows: | |
| de = r["e1"] - r["e0"]; dsh = r["sh1"] - r["sh0"] | |
| if de > 0: de_pos += 1 | |
| if dsh > 0: sh_pos += 1 | |
| flag = " ⟵" if (de > 0 and dsh > 0) else "" | |
| print(f" {str(r['start'])+'→'+str(r['end']):<24}{r['e0']:>+10.2f}{r['e1']:>+12.2f}{de:>+8.2f}" | |
| f"{r['sh0']:>10.2f}{r['sh1']:>12.2f}{dsh:>+8.2f}{flag}") | |
| n = len(rows) | |
| print(" " + "-" * 84) | |
| print(f" Delivery improves EDGE in {de_pos}/{n} windows · improves SHARPE in {sh_pos}/{n} windows") | |
| if de_pos >= math.ceil(0.7 * n) and sh_pos >= math.ceil(0.7 * n): | |
| print(" → CONSISTENT: delivery %% helps across most windows — worth promoting to prod.") | |
| else: | |
| print(" → INCONSISTENT: delivery %% helps only sometimes — the single-window gain was likely noise.") | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--top", type=int, default=5) | |
| ap.add_argument("--windows", type=int, default=6) | |
| ap.add_argument("--test-months", type=int, default=2) | |
| ap.add_argument("--min-train-months", type=int, default=12) | |
| ap.add_argument("--delivery", action="store_true", | |
| help="compare BASE vs BASE+DELIVERY per window (needs training_data_delivery.csv)") | |
| args = ap.parse_args() | |
| if args.delivery: | |
| run_dual(args.top, args.windows, args.test_months, args.min_train_months) | |
| else: | |
| run(args.top, args.windows, args.test_months, args.min_train_months) | |
| if __name__ == "__main__": | |
| main() | |