#!/usr/bin/env python3 """Train XGBoost lock model V2 — pre-trigger features only (no data leakage). Targets ask<0.85 trades where the real edge is. Features computed ONLY from snapshots BEFORE trigger moment. Usage: cd ~/btc_15m_collab/rewrite source .venv/bin/activate python3 tools/xgb_lock_train_v2.py """ import json import numpy as np import pandas as pd import xgboost as xgb from sklearn.metrics import roc_auc_score from collections import defaultdict PAPER_FILE = "data/chainlink_predictor/paper_trades.jsonl" SNAPSHOT_FILE = "data/chainlink_predictor/snapshots.jsonl" MODEL_PATH = "models/lock_xgb.json" FEATURES_PATH = "models/lock_xgb_features.json" # New feature set — all computable from pre-trigger snapshots FEATURE_NAMES = [ "ask", # our side's CLOB ask at trigger "abs_pm_a", # |pm_a| at trigger "left", # seconds remaining when triggered "rv_5m", # 5-min realized volatility "rv_15m", # 15-min realized volatility "sim_a_vol", # exchange price divergence "ask_margin", # our_ask - opp_ask (CLOB directional confidence) "pm_a_accel", # |pm_a| growth in last 30s before trigger "ask_vel", # ask price change rate before trigger "vol_ratio", # our volume / total volume "total_vol", # total CLOB volume (USDC) "sign_changes", # pm_a sign flips before trigger (regime) "pm_a_std", # pm_a standard deviation (stability) "diff", # |sim - pm| at trigger (CB-PM divergence proxy) "trades_count", # CLOB trade count ] def build_snapshot_index(): """Index snapshots by window_id.""" print("Loading snapshots...") snaps_by_wid = defaultdict(list) with open(SNAPSHOT_FILE, "rb") as f: f.seek(0, 2) sz = f.tell() for offset in range(0, sz, 200_000_000): f.seek(offset) f.readline() chunk = f.read(200_000_000) for line in chunk.split(b"\n"): if not line: continue try: s = json.loads(line) wid = str(s.get("window_id", "")) if not wid: continue clob = s.get("clob", {}) snaps_by_wid[wid].append({ "left": s.get("left_sec", 0), "pm_a": s.get("pm_a", 0), "rv_5m": s.get("rv_5m", 0), "rv_15m": s.get("rv_15m", 0), "diff": s.get("diff", 0), "sim_a_vol": s.get("sim_a_vol", 0), "up_ask": clob.get("up_ask", 0), "down_ask": clob.get("down_ask", 0), "up_vol": clob.get("up_buy_usdc", 0), "down_vol": clob.get("down_buy_usdc", 0), "trades": clob.get("trades", 0), }) except Exception: pass print(f" {len(snaps_by_wid)} windows indexed") return snaps_by_wid def compute_pretrigger_features(snaps, trigger_left, direction): """Compute features using ONLY snapshots BEFORE trigger moment.""" before = [s for s in snaps if s["left"] > trigger_left] if len(before) < 5: return None our_ask_key = "up_ask" if direction == "UP" else "down_ask" opp_ask_key = "down_ask" if direction == "UP" else "up_ask" at_trig = before[-1] # closest to trigger our_ask = at_trig[our_ask_key] opp_ask = at_trig[opp_ask_key] our_asks = [s[our_ask_key] for s in before if s[our_ask_key] > 0] if not our_asks: return None pma_vals = [abs(s["pm_a"]) for s in before] # pm_a acceleration (last 30s before trigger vs earlier) recent = [s for s in before if s["left"] <= trigger_left + 35] earlier = [s for s in before if s["left"] > trigger_left + 35] if recent and earlier: pm_a_accel = np.mean([abs(s["pm_a"]) for s in recent]) - np.mean([abs(s["pm_a"]) for s in earlier]) ask_vel = (our_asks[-1] - our_asks[0]) / max(len(our_asks), 1) if len(our_asks) > 3 else 0 else: pm_a_accel = 0 ask_vel = 0 # Volume our_vol = at_trig["up_vol"] if direction == "UP" else at_trig["down_vol"] opp_vol = at_trig["down_vol"] if direction == "UP" else at_trig["up_vol"] total_vol = our_vol + opp_vol vol_ratio = our_vol / (total_vol + 1) # Regime signs = np.sign([s["pm_a"] for s in before]) sign_changes = int(np.sum(np.diff(signs) != 0)) pm_a_std = float(np.std(pma_vals)) return { "ask_margin": our_ask - opp_ask, "pm_a_accel": pm_a_accel, "ask_vel": ask_vel, "vol_ratio": vol_ratio, "total_vol": total_vol, "sign_changes": sign_changes, "pm_a_std": pm_a_std, "rv_5m": at_trig["rv_5m"], "rv_15m": at_trig.get("rv_15m", 0), "sim_a_vol": at_trig["sim_a_vol"], "diff": abs(at_trig["diff"]), "trades_count": at_trig["trades"], } def load_data(snaps_by_wid): """Load lock paper trades and compute pre-trigger features.""" rows = [] with open(PAPER_FILE) as f: for line in f: try: d = json.loads(line) except Exception: continue if d.get("strategy") != "lock" or "won" not in d: continue if abs(d.get("pm_a", 0)) < 60: continue wid = str(d.get("window_id", "")) snaps = snaps_by_wid.get(wid, []) trigger_left = d.get("left", 140) direction = d.get("direction", "UP") feats = compute_pretrigger_features(snaps, trigger_left, direction) if feats is None: continue row = { "won": int(bool(d["won"])), "ask": d["ask"], "abs_pm_a": abs(d["pm_a"]), "left": trigger_left, } row.update(feats) rows.append(row) return pd.DataFrame(rows) def main(): snaps = build_snapshot_index() df = load_data(snaps) print(f"\nLoaded {len(df)} lock trades (pm_a>=60), WR={df['won'].mean():.1%}") # Show ask distribution cheap = df[df["ask"] < 0.85] expensive = df[df["ask"] >= 0.85] print(f" ask<0.85: {len(cheap)} trades, WR={cheap['won'].mean():.1%}") print(f" ask>=0.85: {len(expensive)} trades, WR={expensive['won'].mean():.1%}") # Train on ALL trades (model learns ask is important feature) X = df[FEATURE_NAMES] y = df["won"] # Time-based split split = int(len(df) * 0.7) X_train, X_test = X.iloc[:split], X.iloc[split:] y_train, y_test = y.iloc[:split], y.iloc[split:] print(f"\nTrain: {len(X_train)} (WR={y_train.mean():.1%})") print(f"Test: {len(X_test)} (WR={y_test.mean():.1%})") model = xgb.XGBClassifier( n_estimators=200, max_depth=3, learning_rate=0.05, subsample=0.8, colsample_bytree=0.7, eval_metric="logloss", random_state=42, ) model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False) proba = model.predict_proba(X_test)[:, 1] auc = roc_auc_score(y_test, proba) print(f"\nOut-of-sample AUC: {auc:.3f}") # Threshold analysis — focus on ask<0.85 trades in test set print(f"\nThreshold analysis (test set, ask<0.85 only):") cheap_mask = X_test["ask"] < 0.85 cheap_proba = proba[cheap_mask] cheap_y = y_test[cheap_mask] cheap_X = X_test[cheap_mask] print(f" Total cheap trades in test: {cheap_mask.sum()}") for thresh in [0.50, 0.60, 0.70, 0.75, 0.80, 0.85, 0.90]: keep = cheap_proba >= thresh if keep.sum() == 0: continue wr = cheap_y[keep].mean() n = keep.sum() block_n = (~keep).sum() asks = cheap_X.loc[keep, "ask"] pnl = sum((1 - asks.iloc[i]) * 5 if cheap_y[keep].iloc[i] else -asks.iloc[i] * 5 for i in range(n)) print(f" >={thresh:.2f}: KEEP {n:3d} WR={wr:.0%} PnL=${pnl:+.1f} | BLOCK {block_n:3d}") # Feature importance print(f"\nTop features:") importance = sorted(zip(FEATURE_NAMES, model.feature_importances_), key=lambda x: -x[1]) for name, score in importance: print(f" {name:>15s} {score:.3f}") # Train final model on ALL data print(f"\nTraining final model on all {len(df)} samples...") model_final = xgb.XGBClassifier( n_estimators=200, max_depth=3, learning_rate=0.05, subsample=0.8, colsample_bytree=0.7, eval_metric="logloss", random_state=42, ) model_final.fit(X, y, verbose=False) model_final.save_model(MODEL_PATH) print(f"Model saved to {MODEL_PATH}") with open(FEATURES_PATH, "w") as f: json.dump(FEATURE_NAMES, f) print(f"Feature names saved to {FEATURES_PATH}") if __name__ == "__main__": main()