Files
XauBot/scripts/fast_backtest.py
Vanszs 82d010fcf2 fix: remove leaked models + add honest data/backtest tooling
- Delete all old XGBoost/HMM models trained with the order-block look-ahead
  leak (models/backups/* + root models). They reproduced a fake 63.9% WR /
  2.64 PF that collapses to ~35% WR / 0.95 PF once the leak is fixed.
- scripts/collect_data.py: dedicated raw M1+M15 collector (paginated)
- scripts/fast_backtest.py: vectorized GPU backtest for honest validation
- backtest_live_sync.py: read SYMBOL from env (XM uses GOLD, not XAUUSD)
- stop tracking generated data/training_data.parquet

See upstream report: GifariKemal/xaubot-ai#4
2026-06-06 18:21:50 +07:00

171 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""
Fast Vectorized Backtest (validation)
=====================================
Answers ONE question quickly: does the model's signal produce a positive
expectancy / win-rate under realistic TP/SL — or was the headline win-rate an
artifact of look-ahead leakage?
Speed: features are already in data/training_data.parquet, the model predicts
the WHOLE set in one batched GPU call, and trade outcomes are evaluated with a
single vectorized forward scan (no O(n^2) per-bar recompute).
Trade model:
- Enter when model prob crosses the confidence threshold (long if p>=thr,
short if p<=1-thr).
- Exit via triple barrier: TP = tp_atr*ATR, SL = sl_atr*ATR, else time limit.
- Apply spread cost (points) per round trip.
Usage:
python scripts/fast_backtest.py [--model models/xgboost_model.pkl]
[--data data/training_data.parquet] [--tp-atr 2 --sl-atr 1 --max-hold 24]
[--thr 0.6] [--spread 20] [--device cuda]
"""
import argparse, pickle, warnings, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
warnings.filterwarnings("ignore")
import numpy as np
import polars as pl
import xgboost as xgb
from loguru import logger
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="models/xgboost_model.pkl")
ap.add_argument("--data", default="data/training_data.parquet")
ap.add_argument("--tp-atr", type=float, default=2.0)
ap.add_argument("--sl-atr", type=float, default=1.0)
ap.add_argument("--max-hold", type=int, default=24)
ap.add_argument("--thr", type=float, default=None)
ap.add_argument("--spread", type=float, default=20.0, help="spread cost in points")
ap.add_argument("--device", default="cuda")
args = ap.parse_args()
m = pickle.load(open(args.model, "rb"))
feats = m["feature_names"]
thr = args.thr if args.thr is not None else m.get("confidence_threshold", 0.6)
booster = m["model"]
df = pl.read_parquet(args.data)
d = df
# Required price cols must exist; feature cols may be missing (e.g. regime
# added later by HMM) -> fill with 0 to match live fallback.
price_need = {"high", "low", "close", "atr"}
pmiss = price_need - set(df.columns)
if pmiss:
logger.error(f"data missing price cols: {pmiss}")
return 1
missing_feats = [f for f in feats if f not in d.columns]
if missing_feats:
logger.warning(f"filling {len(missing_feats)} missing feature(s) with 0: {missing_feats}")
d = d.with_columns([pl.lit(0.0).alias(f) for f in missing_feats])
d = d.drop_nulls(subset=list(price_need))
n = d.height
X = np.nan_to_num(d.select(feats).to_numpy().astype(np.float32))
close = d["close"].to_numpy().astype(np.float64)
high = d["high"].to_numpy().astype(np.float64)
low = d["low"].to_numpy().astype(np.float64)
atr = d["atr"].to_numpy().astype(np.float64)
# --- batched GPU prediction (whole dataset at once) ---
dm = xgb.DMatrix(X, feature_names=feats)
try:
booster.set_param({"device": args.device})
except Exception:
pass
prob = booster.predict(dm)
logger.info(f"predicted {n} bars | prob mean={prob.mean():.3f}")
# --- signal: long p>=thr, short p<=1-thr ---
sig = np.zeros(n, dtype=np.int8)
sig[prob >= thr] = 1
sig[prob <= (1 - thr)] = -1
# --- vectorized triple-barrier outcome per entry bar ---
H = args.max_hold
wins = losses = flat = 0
pnl_points = 0.0
rets = []
n_trades = 0
last_exit = -1 # simple non-overlap: no new entry until prior trade exits
for i in range(n - H):
if sig[i] == 0 or i <= last_exit:
continue
a = atr[i]
if a <= 0 or np.isnan(a):
continue
entry = close[i]
direction = sig[i]
if direction == 1:
tp = entry + args.tp_atr * a
sl = entry - args.sl_atr * a
else:
tp = entry - args.tp_atr * a
sl = entry + args.sl_atr * a
outcome = None
for j in range(1, H + 1):
hi, lo = high[i + j], low[i + j]
if direction == 1:
if lo <= sl: # SL first (conservative)
outcome = ("loss", sl); break
if hi >= tp:
outcome = ("win", tp); break
else:
if hi >= sl:
outcome = ("loss", sl); break
if lo <= tp:
outcome = ("win", tp); break
if outcome is None:
exit_px = close[i + H]
r = (exit_px - entry) * direction
outcome = ("win" if r > 0 else "loss", exit_px)
last_exit = i + H
else:
last_exit = i + j
label, exit_px = outcome
gross = (exit_px - entry) * direction
net = gross - args.spread # spread cost per round trip (points)
pnl_points += net
rets.append(net)
n_trades += 1
if net > 0:
wins += 1
else:
losses += 1
rets = np.array(rets) if rets else np.array([0.0])
win_rate = wins / n_trades * 100 if n_trades else 0
gross_win = rets[rets > 0].sum()
gross_loss = -rets[rets < 0].sum()
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
expectancy = rets.mean()
sharpe = rets.mean() / rets.std() * np.sqrt(len(rets)) if rets.std() > 0 else 0
print("\n" + "=" * 50)
print("FAST VECTORIZED BACKTEST (validation)")
print("=" * 50)
print(f"Bars : {n}")
print(f"Threshold : {thr}")
print(f"TP/SL/hold : {args.tp_atr}/{args.sl_atr} ATR, {H} bars")
print(f"Spread cost : {args.spread} pts/trade")
print(f"Total trades : {n_trades}")
print(f"Win rate : {win_rate:.1f}%")
print(f"Profit factor : {pf:.2f}")
print(f"Expectancy : {expectancy:.2f} pts/trade")
print(f"Net P/L (points): {pnl_points:.0f}")
print(f"Sharpe (approx) : {sharpe:.2f}")
print("=" * 50)
return 0
if __name__ == "__main__":
sys.exit(main())