250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
|
|
#!/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()
|