Refactor XGBoost training logic and update optimizer parameters

- Removed the early stopping guard fold in the XGBoost training process to enhance model generalization.
- Updated optimizer version to 3.6 and adjusted search space for regularization parameters (reg_alpha and reg_lambda) to prevent overfitting.
- Implemented a purged inner cross-validation split to avoid leakage between training and validation sets.
- Adjusted scoring penalties for WFE to better reflect model performance discrepancies.
This commit is contained in:
Luke Mosoti
2026-05-15 01:24:34 +03:00
parent 6fce4177f3
commit d0db33062d
4 changed files with 5431 additions and 5642 deletions
+5369 -5598
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+8 -19
View File
@@ -194,25 +194,14 @@ def train_xgb(
model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
X_eval, y_eval = X_test, y_test
else:
# Full IS mode (train_ratio=1.0): carve last 15% as early-stopping guard.
# Time-ordered split so the guard fold represents "near-future" signal
# quality, preventing the model from memorising the IS data.
es_idx = int(len(X_train) * 0.85)
if es_idx > 30:
model.fit(
X_train.iloc[:es_idx], y_train.iloc[:es_idx],
eval_set=[(X_train.iloc[es_idx:], y_train.iloc[es_idx:])],
verbose=False,
)
X_train = X_train.iloc[:es_idx]
y_train = y_train.iloc[:es_idx]
X_eval = X.iloc[es_idx:]
y_eval = y.iloc[es_idx:]
else:
# Not enough rows for a meaningful guard fold — fall back to no ES
model.set_params(early_stopping_rounds=None)
model.fit(X_train, y_train, verbose=False)
X_eval, y_eval = X_train, y_train
# Full IS mode (train_ratio=1.0): train on 100% of the window.
# Do NOT carve out an artificial 15% guard fold — that forces the model to
# specialise on the last market regime in the IS window, which ruins
# generalisation for the upcoming OOS window. Rely on Optuna-tuned
# max_depth and min_child_weight for regularisation instead.
model.set_params(early_stopping_rounds=None)
model.fit(X_train, y_train, verbose=False)
X_eval, y_eval = X_train, y_train
train_acc = accuracy_score(y_train, model.predict(X_train))
test_acc = accuracy_score(y_eval, model.predict(X_eval))
+54 -25
View File
@@ -23,7 +23,6 @@ from itertools import combinations
import numpy as np
import optuna
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit
from src.processor import kalman_smooth
from src.engine_hmm import fit_hmm, predict_states
@@ -45,7 +44,7 @@ except ImportError:
logger.debug("psutil not installed — RAM guard disabled.")
# ── Versioning ─────────────────────────────────────────────────────────────────
OPTIMIZER_VERSION = "3.5" # bump when search space or scoring formula changes
OPTIMIZER_VERSION = "3.6" # bump when search space or scoring formula changes
# ── Rolling WFO window sizes (bars) ───────────────────────────────────────────
WFO_PARAMS = {
@@ -100,8 +99,8 @@ SEARCH_SPACES = {
"trans_cov": (0.001, 0.03, "log"),
"n_states": (3, 4, "int"),
"max_depth": (3, 7, "int"),
"reg_alpha": (1e-6, 0.3, "log"),
"reg_lambda": (0.1, 10.0, "log"),
"reg_alpha": (1e-6, 0.5, "log"),
"reg_lambda": (0.1, 2.0, "log"),
"min_child_weight": (5, 100, "int"),
"learning_rate": (0.005, 0.15, "log"),
"n_estimators": (50, 400, "int"),
@@ -115,8 +114,8 @@ SEARCH_SPACES = {
"trans_cov": (0.001, 0.03, "log"),
"n_states": (4, 4, "cat"),
"max_depth": (3, 7, "int"),
"reg_alpha": (0.05, 5.0, "log"),
"reg_lambda": (0.5, 15.0, "log"),
"reg_alpha": (0.05, 2.0, "log"),
"reg_lambda": (0.5, 5.0, "log"),
"min_child_weight": (3, 30, "int"),
"learning_rate": (0.005, 0.2, "log"),
"n_estimators": (100, 500, "int"),
@@ -130,8 +129,8 @@ SEARCH_SPACES = {
"trans_cov": (0.001, 0.1, "log"),
"n_states": (4, 4, "cat"),
"max_depth": (2, 4, "int"),
"reg_alpha": (1.0, 30.0, "log"),
"reg_lambda": (1.0, 30.0, "log"),
"reg_alpha": (0.5, 5.0, "log"),
"reg_lambda": (0.5, 10.0, "log"),
"min_child_weight": (5, 25, "int"),
"learning_rate": (0.01, 0.15, "log"),
"n_estimators": (200, 600, "int"),
@@ -190,9 +189,35 @@ def _align_hmm_states(model, states: np.ndarray) -> np.ndarray:
return np.vectorize(remap.get)(states)
def _make_inner_cv_splits(X: pd.DataFrame, n_splits: int):
"""Return list of (train_idx, val_idx) from TimeSeriesSplit for inner IS CV."""
return list(TimeSeriesSplit(n_splits=n_splits).split(X))
def _make_purged_inner_cv_splits(
X: pd.DataFrame, n_splits: int, embargo_bars: int = 24
) -> list:
"""Return list of (train_idx, val_idx) with an embargo gap to prevent leakage.
Standard TimeSeriesSplit has no embargo period, causing serial-correlation
leakage between adjacent train and validation windows. This splitter inserts
a gap of ``embargo_bars`` rows between the end of each training fold and the
start of its validation fold.
"""
n = len(X)
fold_size = n // (n_splits + 1)
splits = []
for i in range(1, n_splits + 1):
val_start = i * fold_size
val_end = val_start + fold_size if i < n_splits else n
# Everything before the validation block, minus the embargo gap
train_end = max(0, val_start - embargo_bars)
if train_end < 100: # skip degenerate folds
continue
train_idx = np.arange(0, train_end)
val_idx = np.arange(val_start, val_end)
splits.append((train_idx, val_idx))
return splits
def _score_from_backtest(
@@ -340,9 +365,10 @@ def _run_single_window(
states_is_al = states_is[df_is.index.isin(df_is_al.index)]
states_oos_al = states_oos[df_oos.index.isin(df_oos_al.index)]
# Inner IS CV (consistency check)
# Inner IS CV (consistency check) — purged splitter to avoid leakage
cv_sharpes: list = []
for _tr, _val in _make_inner_cv_splits(X_is, n_cv_folds):
tf_embargo = WFO_PARAMS.get(tf.upper(), WFO_PARAMS["H1"])["embargo_bars"]
for _tr, _val in _make_purged_inner_cv_splits(X_is, n_cv_folds, embargo_bars=tf_embargo):
X_tr, y_tr = X_is.iloc[_tr], y_is.iloc[_tr]
X_val = X_is.iloc[_val]
if len(X_tr) < 200 or len(X_val) < 50:
@@ -593,8 +619,8 @@ def make_objective(df: pd.DataFrame, tf: str, broker: str,
# ── XGBoost params ────────────────────────────────────────────────────
if tf_up == "M5":
max_depth = trial.suggest_int("max_depth", 2, 4)
reg_alpha = trial.suggest_float("reg_alpha", 1.0, 30.0, log=True)
reg_lambda = trial.suggest_float("reg_lambda", 1.0, 30.0, log=True)
reg_alpha = trial.suggest_float("reg_alpha", 0.5, 5.0, log=True) # capped: prevent hmm_state dropout
reg_lambda = trial.suggest_float("reg_lambda", 0.5, 10.0, log=True) # capped: prevent hmm_state dropout
min_child_weight = trial.suggest_int("min_child_weight", 5, 25)
learning_rate = trial.suggest_float("learning_rate", 0.01, 0.15, log=True)
n_estimators = trial.suggest_int("n_estimators", 200, 600, step=50)
@@ -602,8 +628,8 @@ def make_objective(df: pd.DataFrame, tf: str, broker: str,
colsample_bytree = trial.suggest_float("colsample_bytree", 0.4, 0.8)
elif tf_up == "H1":
max_depth = trial.suggest_int("max_depth", 3, 7)
reg_alpha = trial.suggest_float("reg_alpha", 1e-6, 0.3, log=True)
reg_lambda = trial.suggest_float("reg_lambda", 0.1, 10.0, log=True)
reg_alpha = trial.suggest_float("reg_alpha", 1e-6, 0.5, log=True) # capped: prevent hmm_state dropout
reg_lambda = trial.suggest_float("reg_lambda", 0.1, 2.0, log=True) # capped: prevent hmm_state dropout
min_child_weight = trial.suggest_int("min_child_weight", 5, 100)
learning_rate = trial.suggest_float("learning_rate", 0.005, 0.15, log=True)
n_estimators = trial.suggest_int("n_estimators", 50, 400, step=50)
@@ -611,8 +637,8 @@ def make_objective(df: pd.DataFrame, tf: str, broker: str,
colsample_bytree = trial.suggest_float("colsample_bytree", 0.4, 0.9)
else: # M15
max_depth = trial.suggest_int("max_depth", 3, 7)
reg_alpha = trial.suggest_float("reg_alpha", 0.05, 5.0, log=True)
reg_lambda = trial.suggest_float("reg_lambda", 0.5, 15.0, log=True)
reg_alpha = trial.suggest_float("reg_alpha", 0.05, 2.0, log=True) # capped: prevent hmm_state dropout
reg_lambda = trial.suggest_float("reg_lambda", 0.5, 5.0, log=True) # capped: prevent hmm_state dropout
min_child_weight = trial.suggest_int("min_child_weight", 3, 30)
learning_rate = trial.suggest_float("learning_rate", 0.005, 0.2, log=True)
n_estimators = trial.suggest_int("n_estimators", 100, 500, step=50)
@@ -670,12 +696,15 @@ def make_objective(df: pd.DataFrame, tf: str, broker: str,
if std_sharpe > 1.0:
score -= (std_sharpe - 1.0) * 0.5
# WFE penalty: negative WFE means OOS Sharpe is the opposite sign
# of IS CV Sharpe — classic overfitting. Cap score harshly.
if wfe_ratio < 0:
score = min(score, -3.0)
elif wfe_ratio < 0.3:
score *= 0.6
# WFE penalty: penalize models where OOS performance severely diverges
# from the purged IS CV performance. The thresholds are relaxed vs the
# old TimeSeriesSplit-based IS Sharpe, which was inflated by leakage.
if wfe_ratio < -0.5:
score = min(score, -1.0)
elif wfe_ratio < 0.0:
score *= 0.5
elif wfe_ratio < 0.2:
score *= 0.8
tf_floor = SOFT_TRADE_FLOORS.get(tf_up, 60)
if median_trades < tf_floor: