Files

250 lines
8.8 KiB
Python
Raw Permalink Normal View History

#!/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()