""" Train Favorite-Policy Model (v1) — leak-free MS model for the validated strategy. ================================================================================ Trains a LEAK-FREE 1X2 model (drops the result-encoding columns) and saves it plus the feature list and policy metadata. This is the brain of the new system; the favourite-band value policy (odds ~1.5-2.2, model_prob>implied, flat stake) is applied on top of its probabilities at serving time. Honest holdout: trains on the first --holdout-frac of history, evaluates the EXACT policy on the most recent slice (never seen in training), then retrains on ALL history for the saved production artifact. Saves to models/favorite_v1/: model.json, feature_cols.json, metadata.json Usage: python scripts/train_favorite_model.py """ from __future__ import annotations import argparse, json, os, sys, datetime import numpy as np, pandas as pd, xgboost as xgb if sys.stdout and hasattr(sys.stdout, "reconfigure"): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass AI_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CSV = os.path.join(AI_DIR, "data", "training_data_v27.csv") OUT = os.path.join(AI_DIR, "models", "favorite_v1") META = {"match_id","home_team_id","away_team_id","league_id","mst_utc", "score_home","score_away","ht_score_home","ht_score_away"} # Result-encoding leakage — never feed these to the model (train OR serve). LEAKY = {"home_goals_form","away_goals_form","total_goals","ht_total_goals", "squad_diff","home_squad_quality","away_squad_quality", "referee_home_bias","referee_avg_goals"} PARAMS = {"objective":"multi:softprob","num_class":3,"max_depth":5,"eta":0.05, "subsample":0.8,"colsample_bytree":0.8,"tree_method":"hist","verbosity":0} def policy_eval(P, y, O, lo, hi, margin): implied = np.where(O > 1.0, 1.0/O, np.nan) edge = np.where(np.isnan(implied), -9.0, P - implied) pick = edge.argmax(1); pe = edge[np.arange(len(y)), pick]; po = O[np.arange(len(y)), pick] bet = (pe > margin) & (po >= lo) & (po < hi) win = (pick == y) & bet pnl = np.where(win, po-1.0, -1.0)[bet] n = int(bet.sum()) return {"bets": n, "hit_pct": round(100*win.sum()/max(n,1),1), "roi_pct": round(100*pnl.sum()/max(n,1),2), "net_u": round(float(pnl.sum()),1)} def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--lo", type=float, default=1.5) ap.add_argument("--hi", type=float, default=2.2) ap.add_argument("--margin", type=float, default=0.0) ap.add_argument("--holdout-frac", type=float, default=0.15) ap.add_argument("--estimators", type=int, default=300) args = ap.parse_args() print(f"Loading {CSV} ...") df = pd.read_csv(CSV, low_memory=False).sort_values("mst_utc").reset_index(drop=True) sh = pd.to_numeric(df["score_home"], errors="coerce") sa = pd.to_numeric(df["score_away"], errors="coerce") ok = sh.notna() & sa.notna() df, sh, sa = df[ok].reset_index(drop=True), sh[ok.values].values, sa[ok.values].values y = np.where(sh > sa, 0, np.where(sh == sa, 1, 2)) O = df[["odds_ms_h","odds_ms_d","odds_ms_a"]].apply(pd.to_numeric, errors="coerce").fillna(0.0).values feats = [c for c in df.columns if c not in META and not c.startswith("label_") and c not in LEAKY] X = df[feats].apply(pd.to_numeric, errors="coerce").fillna(0.0).values print(f" {len(df):,} rows, {len(feats)} leak-free features") # ── Honest holdout (last slice, never trained on) ── cut = int(len(df) * (1 - args.holdout_frac)) bst = xgb.train(PARAMS, xgb.DMatrix(X[:cut], label=y[:cut]), num_boost_round=args.estimators) Ph = bst.predict(xgb.DMatrix(X[cut:])) acc = float((Ph.argmax(1) == y[cut:]).mean()) hold = policy_eval(Ph, y[cut:], O[cut:], args.lo, args.hi, args.margin) print(f"\nHOLDOUT (last {args.holdout_frac:.0%}, {len(df)-cut:,} matches, never seen):") print(f" MS accuracy: {acc*100:.1f}%") print(f" POLICY band[{args.lo},{args.hi}] margin {args.margin}: {hold}") # ── Production model: retrain on ALL history ── print("\nTraining production model on ALL history ...") final = xgb.train(PARAMS, xgb.DMatrix(X, label=y), num_boost_round=args.estimators) os.makedirs(OUT, exist_ok=True) final.save_model(os.path.join(OUT, "model.json")) with open(os.path.join(OUT, "feature_cols.json"), "w", encoding="utf-8") as f: json.dump(feats, f, ensure_ascii=False, indent=2) meta = { "version": "favorite_v1", "trained_at": datetime.datetime.now().isoformat(timespec="seconds"), "market": "MS", "classes": {"0": "home(1)", "1": "draw(X)", "2": "away(2)"}, "policy": {"odds_lo": args.lo, "odds_hi": args.hi, "margin": args.margin, "stake": "flat 1u", "rule": "bet model's max value edge if picked odds in band", "never": ["longshots odds>=hi", "parlays/combos"]}, "n_train": len(df), "n_features": len(feats), "leaky_excluded": sorted(LEAKY), "holdout_eval": {"accuracy_pct": round(acc*100,1), **hold}, "caveat": "CSV odds are a static capture, not verified closing. Forward paper-trade with real CLV before staking.", } with open(os.path.join(OUT, "metadata.json"), "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) print(f"\n✅ Saved production model to {OUT}/") print(f" model.json, feature_cols.json ({len(feats)} feats), metadata.json") print("\nNEXT: serving wrapper that loads this + applies the policy to upcoming") print("matches, logs paper-trade picks, and we measure real forward CLV/ROI.") if __name__ == "__main__": main()