PaperTrade / research /selection_rolling_oos.py
Khanna, Videh Rakesh Rakesh
research: feature/selection A/B tools (meta-labeling, sector, delivery, rolling OOS)
87f5964
Raw
History Blame Contribute Delete
10.9 kB
#!/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()