Files
FX-ML-Trading-Engine/Q Research/scripts/train_xgb_usdjpy.py
T
2025-11-14 23:16:51 +00:00

235 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""
Train an XGBoost classifier on USDJPY H1 and export model artifacts.
Artifacts are written to: QuantResearch/artifacts/models/usdjpy_h1_xgb/<ts>/
- model.json (xgboost Booster)
- feature_list.json (ordered feature names)
- thresholds.json (p_long, p_exit, val/test metrics)
- meta.json (dataset/costs/params/seed/etc.)
Also updates: QuantResearch/artifacts/models/usdjpy_h1_xgb_latest.json
{"model_dir": "QuantResearch/artifacts/models/usdjpy_h1_xgb/<ts>"}
"""
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Train XGB (long-only) on USDJPY H1")
p.add_argument("--csv", default="QuantResearch/data/raw/USDJPY_H1.csv")
p.add_argument("--symbol", default="USDJPY")
p.add_argument("--horizon", type=int, default=6)
p.add_argument("--train-ratio", type=float, default=0.6)
p.add_argument("--val-ratio", type=float, default=0.2)
p.add_argument("--seed", type=int, default=42)
# Unified costs (match backtest/YAML)
p.add_argument("--spread-pips", type=float, default=2.0)
p.add_argument("--slip-pips", type=float, default=0.3)
p.add_argument("--comm-per-million", type=float, default=0.25)
# Model params (conservative defaults)
p.add_argument("--max-depth", type=int, default=4)
p.add_argument("--n-estimators", type=int, default=300)
p.add_argument("--learning-rate", type=float, default=0.05)
p.add_argument("--min-child-weight", type=float, default=1.0)
p.add_argument("--reg-lambda", type=float, default=1.0)
# Output base dir
p.add_argument("--out", default="QuantResearch/artifacts/models/usdjpy_h1_xgb")
return p.parse_args()
def _pip_value(symbol: str) -> float:
return 0.01 if symbol.upper().endswith("JPY") else 0.0001
def compute_cost_return(symbol: str, price: pd.Series, spread_pips: float, slip_pips: float, comm_per_million: float) -> pd.Series:
pip = _pip_value(symbol)
# Approx trade cost (fractional): spread + 2*slippage in price terms + commission fraction
frac_px = (spread_pips + 2.0 * slip_pips) * pip / price
frac_comm = (comm_per_million / 1_000_000.0)
return frac_px + frac_comm
def rsi(series: pd.Series, period: int = 14) -> pd.Series:
delta = series.diff()
up = delta.clip(lower=0)
down = -delta.clip(upper=0)
roll_up = up.rolling(period).mean()
roll_down = down.rolling(period).mean()
rs = roll_up / roll_down
return 100 - (100 / (1 + rs))
def atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series:
prev_close = close.shift(1)
tr = pd.concat([(high - low).abs(), (high - prev_close).abs(), (low - prev_close).abs()], axis=1).max(axis=1)
return tr.rolling(period).mean()
def add_time_features(df: pd.DataFrame) -> pd.DataFrame:
ts = pd.to_datetime(df["time"], utc=True)
hour = ts.dt.hour.astype(float)
dow = ts.dt.dayofweek.astype(float)
df["hour_sin"], df["hour_cos"] = np.sin(2 * np.pi * hour / 24.0), np.cos(2 * np.pi * hour / 24.0)
df["dow_sin"], df["dow_cos"] = np.sin(2 * np.pi * dow / 7.0), np.cos(2 * np.pi * dow / 7.0)
return df
def build_features(df: pd.DataFrame, fast: int = 20, slow: int = 80, rsi_p: int = 14, atr_p: int = 14) -> Tuple[pd.DataFrame, List[str]]:
out = df.copy()
out = add_time_features(out)
out["ret_1"] = out["close"].pct_change(1)
out["ret_3"] = out["close"].pct_change(3)
out["ret_6"] = out["close"].pct_change(6)
out["vol_24"] = out["close"].pct_change(1).rolling(24).std()
sma_f = out["close"].rolling(fast).mean()
sma_s = out["close"].rolling(slow).mean()
out["sma_diff"] = (sma_f - sma_s) / out["close"]
out["rsi"] = rsi(out["close"], rsi_p)
out["atr_norm"] = atr(out["high"], out["low"], out["close"], atr_p) / out["close"]
feats = [
"ret_1", "ret_3", "ret_6", "vol_24",
"sma_diff", "rsi", "atr_norm",
"hour_sin", "hour_cos", "dow_sin", "dow_cos",
]
return out, feats
def forward_return(close: pd.Series, horizon: int) -> pd.Series:
return close.shift(-horizon) / close - 1.0
def pick_thresholds(p_val: np.ndarray, fwd_val: np.ndarray, cost_val: np.ndarray) -> Dict[str, float]:
best = {"thr_long": 0.6, "thr_exit": 0.5, "sharpe": -np.inf, "trades": 0}
for thr in np.linspace(0.55, 0.8, 11):
for thr_exit in np.linspace(0.45, 0.6, 16):
chosen = p_val >= thr
if not np.any(chosen):
continue
net = (fwd_val - cost_val)[chosen]
if net.size < 50:
continue
mu = float(np.nanmean(net))
sd = float(np.nanstd(net, ddof=1))
sharpe = (mu / sd) * math.sqrt(252 * 24) if sd > 0 else -np.inf
if sharpe > best["sharpe"]:
best = {"thr_long": float(thr), "thr_exit": float(thr_exit), "sharpe": sharpe, "trades": int(net.size)}
return best
def main() -> None:
try:
import xgboost as xgb # requires xgboost==1.7.6 per requirements
except Exception as exc:
raise SystemExit("xgboost is required. Please install xgboost==1.7.6.") from exc
args = parse_args()
np.random.seed(args.seed)
df = pd.read_csv(args.csv)
if "time" not in df.columns:
raise SystemExit("CSV must include 'time' column")
df = df[["time", "open", "high", "low", "close", "volume"]].copy()
df_feat, feat_list = build_features(df)
fwd = forward_return(df_feat["close"], args.horizon)
costs = compute_cost_return(args.symbol, df_feat["close"], args.spread_pips, args.slip_pips, args.comm_per_million)
# Label with cost margin
y = pd.Series(np.where(fwd > costs, 1, np.where(fwd < -costs, 0, np.nan)), index=df_feat.index)
data = df_feat.assign(y=y, cost=costs).dropna(subset=feat_list + ["y"]).reset_index(drop=True)
X = data[feat_list].to_numpy()
y_arr = data["y"].astype(int).to_numpy()
fwd_arr = fwd.loc[data.index].to_numpy()
cost_arr = data["cost"].to_numpy()
n = len(data)
i_train = int(n * args.train_ratio)
i_val = int(n * (args.train_ratio + args.val_ratio))
if i_val >= n:
i_val = n - max(1, n // 10)
X_train, y_train = X[:i_train], y_arr[:i_train]
X_val, y_val = X[i_train:i_val], y_arr[i_train:i_val]
X_test, y_test = X[i_val:], y_arr[i_val:]
fwd_val, cost_val = fwd_arr[i_train:i_val], cost_arr[i_train:i_val]
fwd_test, cost_test = fwd_arr[i_val:], cost_arr[i_val:]
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=feat_list)
dval = xgb.DMatrix(X_val, label=y_val, feature_names=feat_list)
dtest = xgb.DMatrix(X_test, label=y_test, feature_names=feat_list)
params = {
"objective": "binary:logistic",
"eval_metric": "logloss",
"max_depth": args.max_depth,
"eta": args.learning_rate,
"lambda": args.reg_lambda,
"min_child_weight": args.min_child_weight,
"subsample": 0.9,
"colsample_bytree": 0.9,
"seed": args.seed,
}
evals = [(dtrain, "train"), (dval, "val")]
booster = xgb.train(params, dtrain, num_boost_round=args.n_estimators, evals=evals, early_stopping_rounds=50, verbose_eval=False)
p_val = booster.predict(dval)
th = pick_thresholds(p_val, fwd_val, cost_val)
p_test = booster.predict(dtest)
chosen = p_test >= th["thr_long"]
net = (fwd_test - cost_test)[chosen]
test_trades = int(net.size)
mu = float(np.nanmean(net)) if net.size else 0.0
sd = float(np.nanstd(net, ddof=1)) if net.size > 1 else 0.0
test_sharpe = (mu / sd) * math.sqrt(252 * 24) if sd > 0 else 0.0
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
model_dir = Path(args.out).with_suffix("") / ts
model_dir.mkdir(parents=True, exist_ok=True)
# Save artifacts
booster.save_model(str(model_dir / "model.json"))
(model_dir / "feature_list.json").write_text(json.dumps(feat_list, indent=2), encoding="utf-8")
thresholds = {
"p_long": float(th["thr_long"]),
"p_exit": float(th["thr_exit"]),
"val_sharpe": float(th["sharpe"]),
"val_trades": int(th["trades"]),
"test_sharpe": float(test_sharpe),
"test_trades": int(test_trades),
}
(model_dir / "thresholds.json").write_text(json.dumps(thresholds, indent=2), encoding="utf-8")
meta = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"symbol": args.symbol,
"csv": args.csv,
"horizon": args.horizon,
"splits": {"train": int(i_train), "val": int(i_val - i_train), "test": int(n - i_val)},
"seed": int(args.seed),
"features": feat_list,
"xgb_params": params,
"best_iteration": int(getattr(booster, "best_iteration", 0) or 0),
"thresholds": thresholds,
"costs": {"spread_pips": args.spread_pips, "slip_pips": args.slip_pips, "comm_per_million": args.comm_per_million},
"git_commit": None,
}
(model_dir / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
latest_path = Path(args.out).with_suffix("").parent / "usdjpy_h1_xgb_latest.json"
latest_path.parent.mkdir(parents=True, exist_ok=True)
latest_path.write_text(json.dumps({"model_dir": str(model_dir)}, indent=2), encoding="utf-8")
print(f"Saved model artifacts to {model_dir}")
if __name__ == "__main__":
main()