feat: Implement Z-Score signal evaluation for adaptive trading strategy

- Added `SignalEvaluator` class for shared adaptive signal evaluation logic across backtesting, optimization, and live trading.
- Introduced `compute_regime_stats` function to calculate per-HMM-state probability statistics for Z-Score calibration.
- Enhanced `vectorized_backtest` and `run_walk_forward` functions to support regime statistics and Z-Score signal generation.
- Updated `backtester.py` to include new signal computation method `compute_signals_zscore`.
- Modified `mt5_trader.py` to utilize the new signal evaluation logic and added regime stability tracking.
- Adjusted `optimizer.py` to compute regime statistics during model training and store them for later use.
- Updated validation logic in `validator.py` to incorporate regime statistics for improved signal evaluation.
- Refactored existing signal routing logic to accommodate new adaptive Z-Score evaluation framework.
This commit is contained in:
Luke Mosoti
2026-04-22 15:27:34 +03:00
parent c74f546078
commit 470f28142f
8 changed files with 714 additions and 127 deletions
+24 -12
View File
@@ -21,7 +21,7 @@ from src.engine_xgb import (
export_onnx, save_xgb, load_xgb, ONNX_PATH, FEATURE_COLS,
train_xgb_ensemble, get_predictions_ensemble,
save_xgb_ensemble, load_xgb_ensemble, export_onnx_ensemble, ENSEMBLE_PKL_PATH,
get_ensemble_path, TF_TRAIN_RATIO,
get_ensemble_path, TF_TRAIN_RATIO, compute_regime_stats,
)
from src.optimizer import run_optimization, get_best_params, _score_result as _calc_score
from src.backtester import vectorized_backtest, format_payout
@@ -119,14 +119,17 @@ def _train_for_tf(tf: str, balance: float, broker: str, params: dict):
_, probabilities = get_predictions_ensemble(models_ensemble, thresholds, X)
states_aligned = states[df.index.isin(df_aligned.index)]
split_idx = metrics.get("split_idx")
# Compute IS regime statistics for Z-Score signal calibration
_X_is = X.iloc[:split_idx] if split_idx else X
_hs_is = states_aligned[:len(_X_is)]
metrics["regime_stats"] = compute_regime_stats(models_ensemble, thresholds, _X_is, _hs_is)
result = vectorized_backtest(
df_aligned, probabilities, states_aligned,
split_idx=split_idx,
account_size=balance,
broker=broker,
tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
regime_stats=metrics["regime_stats"],
)
return result, model_hmm, state_map, models_ensemble, thresholds, metrics, df_aligned, states_aligned, X, probabilities
@@ -245,6 +248,10 @@ def cmd_wfa(args):
)
_, probs = get_predictions_ensemble(models_e, thresholds_e, X)
states_aln = states[df.index.isin(df_aligned.index)]
_cmp_split = metrics_e.get("split_idx") or int(len(X) * 0.70)
_X_is_cmp = X.iloc[:_cmp_split]
_hs_is_cmp = states_aln[:len(_X_is_cmp)]
_regime_stats_cmp = compute_regime_stats(models_e, thresholds_e, _X_is_cmp, _hs_is_cmp)
print(
f" Dataset: {len(df_aligned)} bars "
@@ -254,13 +261,12 @@ def cmd_wfa(args):
wfa = run_walk_forward(
df_aligned, probs, states_aln,
train_days = train_days,
test_days = test_days,
account_size = balance,
broker = broker,
tf = tf,
prob_threshold = params.get("prob_threshold"),
short_threshold = params.get("short_threshold"),
train_days = train_days,
test_days = test_days,
account_size = balance,
broker = broker,
tf = tf,
regime_stats = _regime_stats_cmp,
)
n_win = wfa["n_windows"]
@@ -719,6 +725,13 @@ def cmd_report(args):
_, probabilities = get_predictions_ensemble(models_xgb, thresholds_xgb, X)
states_aligned = states[df.index.isin(df_aligned.index)]
# Ensure regime_stats are present (may be absent in pre-Z-Score saved models)
if not metrics.get("regime_stats"):
_rs_split = metrics.get("split_idx") or int(len(X) * 0.8)
_X_is = X.iloc[:_rs_split]
_hs_is = states_aligned[:len(_X_is)]
metrics["regime_stats"] = compute_regime_stats(models_xgb, thresholds_xgb, _X_is, _hs_is)
# split_idx in saved metrics may come from a different TF's training run
# (e.g. M15 split_idx ~186k used in an H1 report with only ~58k bars).
# Recompute from current data when the stored value doesn't fit.
@@ -729,8 +742,7 @@ def cmd_report(args):
result = vectorized_backtest(
df_aligned, probabilities, states_aligned,
split_idx=split_idx, account_size=balance, broker=broker, tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
regime_stats=metrics.get("regime_stats"),
)
arm = AdaptiveRiskManager(balance, broker=broker)
Binary file not shown.
+63 -11
View File
@@ -2,6 +2,7 @@ import numpy as np
import pandas as pd
from src.logger import setup_logger
from src.risk_manager import BROKER_CONFIGS, AdaptiveRiskManager
from src.signal_evaluator import SignalEvaluator
logger = setup_logger(__name__)
@@ -107,6 +108,39 @@ def compute_signals(probabilities, hmm_states, threshold=PROB_THRESHOLD,
return (buy - sell + mr_buy - mr_sell).clip(-1, 1)
def compute_signals_zscore(
probabilities: np.ndarray,
hmm_states: np.ndarray,
regime_stats: dict,
gmm_clusters: np.ndarray | None = None,
) -> np.ndarray:
"""Vectorised Z-Score signal generation — used when regime_stats are available.
No live-safety gates (consecutive-bars / transition-prob / Bollinger Bands)
are applied here. Those are execution safeguards added only by the live
bridge. This function evaluates the *pure signal edge* so the backtester
and optimiser score the same logic the SignalEvaluator implements.
Signal encoding matches :func:`compute_signals`: ``1=BUY, -1=SELL, 0=no trade``.
MR_BUY and MR_SELL are collapsed to +1 / −1 respectively.
"""
evaluator = SignalEvaluator(regime_stats)
n = len(probabilities)
signals = np.zeros(n, dtype=np.int8)
for i in range(n):
gmc = int(gmm_clusters[i]) if gmm_clusters is not None else 1
sig, _ = evaluator.evaluate_signal_fast(
prob_buy=float(probabilities[i]),
hmm_state=int(hmm_states[i]),
gmm_cluster=gmc,
)
if sig in ("BUY", "MR_BUY"):
signals[i] = 1
elif sig in ("SELL", "MR_SELL"):
signals[i] = -1
return signals
def compute_position_sizes(
signals,
atr_normalized,
@@ -335,6 +369,7 @@ def vectorized_backtest(
tf: str = "H1",
prob_threshold: float = None,
short_threshold: float = None,
regime_stats: dict = None,
):
"""Vectorized backtest with adaptive session limits and broker costs.
@@ -346,21 +381,36 @@ def vectorized_backtest(
account_size: Account balance in USD; drives adaptive session limits.
broker: Broker profile key from ``BROKER_CONFIGS``.
tf: Timeframe string used for correct annualization ("H1" or "M15").
prob_threshold: BUY threshold — signal when prob > this value.
Defaults to PROB_THRESHOLD (0.65) if not supplied.
short_threshold: SELL threshold — signal when prob < this value.
Defaults to ``1 - prob_threshold`` if not supplied,
giving a symmetric no-trade zone around 0.5.
prob_threshold: BUY threshold (legacy fixed-threshold mode). Ignored
when *regime_stats* is supplied.
short_threshold: SELL threshold (legacy). Ignored when *regime_stats*
is supplied.
regime_stats: Per-HMM-state probability statistics produced by
:func:`~src.engine_xgb.compute_regime_stats`. When
provided, signals are generated via Z-Score calibration
(:func:`compute_signals_zscore`) instead of the legacy
fixed-threshold method.
"""
log_returns = df["log_return"].values
atr_norm = df["atr_normalized"].values
buy_th = prob_threshold if prob_threshold is not None else PROB_THRESHOLD
raw_signals = compute_signals(
probabilities, hmm_states,
threshold=buy_th,
short_threshold=short_threshold, # None → symmetric default inside
)
# ── Signal generation ─────────────────────────────────────────────────
if regime_stats:
gmm_clusters = (
df["gmm_vol_cluster"].values
if "gmm_vol_cluster" in df.columns
else None
)
raw_signals = compute_signals_zscore(
probabilities, hmm_states, regime_stats, gmm_clusters
)
else:
buy_th = prob_threshold if prob_threshold is not None else PROB_THRESHOLD
raw_signals = compute_signals(
probabilities, hmm_states,
threshold=buy_th,
short_threshold=short_threshold, # None → symmetric default inside
)
# Spread-Aware Adaptive Filter: suppress signals where ATR / spread is below
# the TF-specific minimum efficiency threshold.
@@ -477,6 +527,7 @@ def run_walk_forward(
tf: str = "H1",
prob_threshold: float = None,
short_threshold: float = None,
regime_stats: dict = None,
) -> dict:
"""Roll a fixed-weight model across time to compute Walk-Forward Efficiency.
@@ -539,6 +590,7 @@ def run_walk_forward(
account_size=account_size,
broker=broker,
tf=tf,
regime_stats=regime_stats,
prob_threshold=prob_threshold,
short_threshold=short_threshold,
)
+49 -1
View File
@@ -424,13 +424,61 @@ def get_predictions_ensemble(
return predictions, probabilities
def compute_regime_stats(
models: dict,
thresholds: tuple[float, float],
X_is: pd.DataFrame,
hmm_states_is: np.ndarray,
) -> dict:
"""Compute per-HMM-state probability statistics from In-Sample data.
These statistics calibrate the :class:`~src.signal_evaluator.SignalEvaluator`
Z-Score thresholds. Call on IS data only (no look-ahead into OOS bars).
Args:
models: ``{"low": xgb, "med": xgb, "high": xgb}`` trained ensemble.
thresholds: ``(p33, p66)`` ATR percentile from IS data.
X_is: IS feature DataFrame (same index slice as used for training).
hmm_states_is: HMM state labels aligned with ``X_is``.
Returns:
Mapping of ``state_id → {"mean": float, "std": float, "count": int}``.
"""
_, probs = get_predictions_ensemble(models, thresholds, X_is)
regime_stats: dict = {}
for state in sorted(np.unique(hmm_states_is)):
mask = hmm_states_is == state
n = int(mask.sum())
state_p = probs[mask]
if n >= 30:
mean = float(np.mean(state_p))
std = float(max(np.std(state_p), 0.02))
else:
logger.warning(
"State %d: only %d IS samples — using fallback stats (mean=0.50, std=0.15)",
state, n,
)
mean, std = 0.50, 0.15
regime_stats[int(state)] = {"mean": mean, "std": std, "count": n}
logger.info(
" Regime stats state=%d mean=%.4f std=%.4f n=%d", state, mean, std, n
)
return regime_stats
def save_xgb_ensemble(
models: dict,
thresholds: tuple[float, float],
metrics: dict,
path: Path = ENSEMBLE_PKL_PATH,
) -> None:
"""Persist the ensemble (3 models + thresholds + metadata) to a single pkl."""
"""Persist the ensemble (3 models + thresholds + metadata) to a single pkl.
``metrics`` may include a ``"regime_stats"`` key (added by callers after
:func:`compute_regime_stats`) which is stored transparently alongside the
model weights and loaded back by :func:`load_xgb_ensemble`.
"""
path.parent.mkdir(parents=True, exist_ok=True)
joblib.dump({"models": models, "thresholds": thresholds, "metrics": metrics}, path)
logger.info("Ensemble saved to %s", path)
+149 -64
View File
@@ -41,6 +41,7 @@ from src.engine_xgb import (
get_ensemble_path, ENSEMBLE_PKL_PATH as XGB_GENERIC_PATH,
)
from src.risk_manager import AdaptiveRiskManager, BROKER_CONFIGS, CENT_MULTIPLIER, DailyEquityGate
from src.signal_evaluator import SignalEvaluator
logger = setup_logger(__name__)
@@ -802,6 +803,77 @@ def send_market_order(
# Live execution loop
# ─────────────────────────────────────────────────────────────────────────────
def _update_regime_stability(tracker: dict, hmm_state: int) -> dict:
"""Update per-bar regime stability state and return a stability info dict.
The *tracker* dict is mutated in-place and must be initialised once per
loop run as::
tracker = {"current_state": None, "consecutive_bars": 0,
"previous_state": None, "exited_from_state": None}
"""
if tracker["current_state"] is None:
tracker["current_state"] = hmm_state
tracker["consecutive_bars"] = 1
tracker["previous_state"] = hmm_state
tracker["exited_from_state"] = None
elif hmm_state != tracker["current_state"]:
logger.info(
"[REGIME CHANGE] %d → %d after %d bars",
tracker["current_state"], hmm_state, tracker["consecutive_bars"],
)
tracker["exited_from_state"] = tracker["current_state"]
tracker["previous_state"] = tracker["current_state"]
tracker["current_state"] = hmm_state
tracker["consecutive_bars"] = 1
else:
tracker["consecutive_bars"] += 1
is_chop = hmm_state in (2, 3)
return {
"is_chop": is_chop,
"consecutive_bars": tracker["consecutive_bars"],
"is_stable_chop": is_chop and tracker["consecutive_bars"] >= 3,
"just_entered_state": tracker["consecutive_bars"] == 1,
"regime_changed_this_bar": tracker["consecutive_bars"] == 1,
"exited_from_state": tracker["exited_from_state"],
"previous_state": tracker["previous_state"],
}
def _get_transition_prob(model_hmm, hmm_state: int) -> float:
"""Return P(state → state) from the HMM transition matrix (self-transition)."""
try:
transmat = model_hmm.transmat_
if hmm_state < transmat.shape[0]:
return float(transmat[hmm_state, hmm_state])
except Exception:
pass
return 0.70
def _calculate_bb_position(close_prices: np.ndarray, period: int = 20,
num_std: float = 2.0) -> float:
"""Return price position within Bollinger Bands: 0 = lower band, 1 = upper band.
Returns 0.5 (neutral) if there are insufficient bars or the band is too narrow.
"""
if len(close_prices) < period:
return 0.5
try:
sma = np.mean(close_prices[-period:])
std = np.std(close_prices[-period:])
upper = sma + num_std * std
lower = sma - num_std * std
bw = upper - lower
if bw < 1e-6:
return 0.5
pos = (close_prices[-1] - lower) / bw
return float(np.clip(pos, 0.0, 1.0))
except Exception:
return 0.5
def run_live_loop(
tf: str = "H1",
broker: str = "headway_cent",
@@ -880,6 +952,24 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
except FileNotFoundError:
raise FileNotFoundError("XGB ensemble model not found. Run --mode train first.")
# Load regime statistics and build the adaptive signal evaluator
regime_stats = xgb_meta.get("regime_stats", {})
signal_evaluator = SignalEvaluator(regime_stats)
if regime_stats:
logger.info(
"Z-Score signal mode: regime stats loaded for %d states", len(regime_stats)
)
for _s, _st in regime_stats.items():
logger.info(
" State %d: mean=%.4f std=%.4f n=%d",
_s, _st["mean"], _st["std"], _st["count"],
)
else:
logger.warning(
"No regime_stats found in model — fixed-threshold fallback active. "
"Re-train to enable Z-Score calibration."
)
# Resolve Kalman params from Optuna study or TF defaults
try:
from src.optimizer import get_best_params
@@ -930,6 +1020,16 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
equity_gate = DailyEquityGate(tf=tf)
equity_gate.reset_day(account_size)
# Regime stability tracker for adaptive Z-Score gating
regime_stability_tracker: dict = {
"current_state": None,
"consecutive_bars": 0,
"previous_state": None,
"exited_from_state": None,
}
# Rolling close-price cache for Bollinger Band confluence filter (live only)
close_prices_cache: list = []
arm = AdaptiveRiskManager(account_size, tf=tf, broker=broker)
logger.info(
"Live loop started — TF=%s broker=%s balance=$%.2f %s",
@@ -1111,6 +1211,11 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
continue
last_bar_time = bar_time
# Update rolling close cache for Bollinger Band confluence filter
close_prices_cache.append(float(bars[0]["close"]))
if len(close_prices_cache) > 50:
close_prices_cache.pop(0)
# ── 3. Refresh telemetry for margin/display (risk sizing always
# uses the caller-supplied account_size, not the live MT5
# balance, because demo accounts carry arbitrary balances).
@@ -1227,98 +1332,78 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
time.sleep(POLL_INTERVAL_SEC)
continue
# ── 9. Signal routing — mirrors compute_signals() in backtester ──
# Trend regime: trade WITH model conviction (Bull→BUY, Bear→SELL).
# Mean Reversion regime: trade AGAINST extremes in Chop states.
is_chop = (hmm_state >= CHOP_STATE)
mr_buy_threshold = short_threshold - 0.10
mr_sell_threshold = prob_threshold + 0.10
# ── 9. Signal routing — adaptive Z-Score evaluation ──────────
# Updates regime stability and evaluates via SignalEvaluator when
# regime_stats are available; falls back to fixed-threshold logic
# for models trained before Z-Score calibration was added.
_stability = _update_regime_stability(regime_stability_tracker, hmm_state)
_t_prob = _get_transition_prob(model_hmm, hmm_state)
_bb_pos = _calculate_bb_position(np.array(close_prices_cache))
_gmc = gmm_cluster if gmm_cluster >= 0 else 1
_sig_eval = signal_evaluator.evaluate_signal(
prob_buy=prob,
hmm_state=hmm_state,
gmm_cluster=_gmc,
stability=_stability,
bb_position=_bb_pos,
transition_prob=_t_prob,
)
_sig_str = _sig_eval["signal"]
if prob > prob_threshold and hmm_state == BULL_STATE:
if _sig_str == "BUY":
direction = "BUY"
order_type = mt5.ORDER_TYPE_BUY
signal_type = "trend"
elif prob < short_threshold and hmm_state == BEAR_STATE:
logger.info(
"[SIGNAL] BUY (trend) | z=%.2f | %s",
_sig_eval["confidence"], _sig_eval["reason"],
)
elif _sig_str == "SELL":
direction = "SELL"
order_type = mt5.ORDER_TYPE_SELL
signal_type = "trend"
elif is_chop and prob < mr_buy_threshold:
logger.info(
"[SIGNAL] SELL (trend) | z=%.2f | %s",
_sig_eval["confidence"], _sig_eval["reason"],
)
elif _sig_str == "MR_BUY":
direction = "BUY"
order_type = mt5.ORDER_TYPE_BUY
signal_type = "mean_reversion"
logger.info(
"[MEAN REVERSION SIGNAL] BUY — Chop state=%d prob=%.3f < %.3f (mr_buy ceiling)",
hmm_state, prob, mr_buy_threshold,
"[SIGNAL] MR_BUY | z=%.2f | %s",
_sig_eval["confidence"], _sig_eval["reason"],
)
if gmm_cluster == 2:
logger.warning(
"[MR WARNING] High-Vol environment (GMM cluster=2). "
"Mean Reversion has lower edge in breakout conditions — watch closely."
"[MR WARNING] High-vol environment (GMM cluster=2) — "
"MR has lower edge in breakout conditions."
)
elif is_chop and prob > mr_sell_threshold:
elif _sig_str == "MR_SELL":
direction = "SELL"
order_type = mt5.ORDER_TYPE_SELL
signal_type = "mean_reversion"
logger.info(
"[MEAN REVERSION SIGNAL] SELL — Chop state=%d prob=%.3f > %.3f (mr_sell floor)",
hmm_state, prob, mr_sell_threshold,
"[SIGNAL] MR_SELL | z=%.2f | %s",
_sig_eval["confidence"], _sig_eval["reason"],
)
if gmm_cluster == 2:
logger.warning(
"[MR WARNING] High-Vol environment (GMM cluster=2). "
"Mean Reversion has lower edge in breakout conditions — watch closely."
"[MR WARNING] High-vol environment (GMM cluster=2) — "
"MR has lower edge in breakout conditions."
)
else:
# ── Logic Audit — structured diagnostic explaining why no trade fired ──
# ── Logic Audit — Z-Score diagnostic ─────────────────────
regime_desc = ("BULL" if hmm_state == BULL_STATE
else "BEAR" if hmm_state == BEAR_STATE
else "CHOP")
signal_desc = "UP" if prob > 0.50 else "DOWN"
# Classify as Divergence (HMM and ML disagree on direction) or
# Alignment (same direction but probability hasn't crossed the hurdle).
if hmm_state == BULL_STATE and prob < 0.50:
decision = (
f"DIVERGENCE — HMM says {regime_desc} but ML leans {signal_desc}. "
"Safety filter active: no trade."
)
elif hmm_state == BEAR_STATE and prob > 0.50:
decision = (
f"DIVERGENCE — HMM says {regime_desc} but ML leans {signal_desc}. "
"Safety filter active: no trade."
)
elif hmm_state == BULL_STATE:
decision = (
f"ALIGNMENT — Both Bullish. "
f"Prob {prob:.3f} needs >{prob_threshold:.3f} "
f"(gap: {prob - prob_threshold:+.3f})"
)
elif hmm_state == BEAR_STATE:
decision = (
f"ALIGNMENT — Both Bearish. "
f"Prob {prob:.3f} needs <{short_threshold:.3f} "
f"(gap: {short_threshold - prob:+.3f})"
)
elif is_chop:
decision = (
f"CHOP REGIME — MR thresholds: "
f"BUY<{mr_buy_threshold:.3f} / SELL>{mr_sell_threshold:.3f}. "
f"Prob={prob:.3f} not extreme enough."
)
else:
decision = f"Scanning... state={hmm_state} prob={prob:.3f}"
_z = _sig_eval["confidence"]
logger.info(
"[LOGIC AUDIT] %s Regime | ML %s (%.3f) | %s",
regime_desc, signal_desc, prob, decision,
)
# Show exact distance to both thresholds so the user can visualise
# how close they are to an entry without needing to do the maths.
# Positive = would have fired in that direction; negative = still needs to move.
logger.info(
" Distance -> BUY: %+.3f | SELL: %+.3f",
prob - prob_threshold, # positive means prob already > buy hurdle
short_threshold - prob, # positive means prob already < sell hurdle
"[LOGIC AUDIT] %s Regime | state=%d prob=%.3f z=%.2f "
"bars=%d P(stay)=%.2f BB=%.2f | %s",
regime_desc, hmm_state, prob, _z,
_stability["consecutive_bars"], _t_prob, _bb_pos,
_sig_eval["reason"],
)
time.sleep(POLL_INTERVAL_SEC)
continue
+11 -36
View File
@@ -21,7 +21,9 @@ import optuna
from src.processor import process_pipeline
from src.engine_hmm import fit_hmm
from src.engine_xgb import prepare_features, train_xgb_ensemble, get_predictions_ensemble
from src.engine_xgb import (
prepare_features, train_xgb_ensemble, get_predictions_ensemble, compute_regime_stats,
)
from src.backtester import vectorized_backtest, format_payout
from src.risk_manager import SMALL_ACCOUNT_THRESHOLD
from src.logger import setup_logger
@@ -143,38 +145,6 @@ def make_objective(balance: float = 15.0, broker: str = "standard", tf: str = "H
n_states = trial.suggest_categorical("n_states", [4])
else:
n_states = trial.suggest_int("n_states", 3, 4)
# M5 probs cluster below 0.56 in live — narrow range forces the
# optimizer to find high-frequency signals in the 0.50–0.55 window
# for cent accounts. Standard accounts have higher per-trade costs
# so a more conservative range (0.55–0.60) ensures spread is covered.
# H1/M15: wide range (0.50–0.85). Signal locking in the backtester ensures
# trades are counted per entry (not per active bar), so the Recovery Factor
# Threshold ranges are TF-specific to match each timeframe's edge profile.
# The spread-aware adaptive filter (ATR/spread ≥ 1.25) handles low-conviction
# bars at the backtest level, so these ranges reflect natural bar confidence.
# short_threshold is symmetric: short = 1 - prob (≈ same conviction for SELL).
if tf.upper() == "M5":
# Scalp: lower hurdle, high-frequency positive-expectancy clusters
prob_threshold = trial.suggest_float("prob_threshold", 0.52, 0.65)
short_threshold = trial.suggest_float("short_threshold", 0.30, 0.48)
elif tf.upper() == "H1":
# Swing: moderate conviction thresholds — H1 gold XGBoost probs
# cluster in the 0.45–0.65 range in live data; the 0.65–0.80 range
# used previously produced only 1 trade per 3-6 months (unusable).
# 0.52–0.65 / 0.35–0.48 matches the model's actual output distribution
# while still requiring more conviction than M15.
prob_threshold = trial.suggest_float("prob_threshold", 0.52, 0.65)
short_threshold = trial.suggest_float("short_threshold", 0.35, 0.48)
else:
# M15 — intermediate between M5 scalp and H1 swing
prob_threshold = trial.suggest_float("prob_threshold", 0.55, 0.65)
short_threshold = trial.suggest_float("short_threshold", 0.35, 0.45)
# Guard: thresholds must not overlap — a crossover means every bar gets
# both a BUY and SELL signal simultaneously, which is nonsensical.
if short_threshold >= prob_threshold:
return -10.0
# M5 uses shallower trees (2-3) to prevent IS memorisation across the
# large bar count; heavier L1 reg (1-20) to sparsify feature weights.
# H1/M15: max_depth 3-6 allows the model to map complex HMM-state ×
@@ -256,16 +226,21 @@ def make_objective(balance: float = 15.0, broker: str = "standard", tf: str = "H
)
_, probabilities = get_predictions_ensemble(models, thresholds, X)
# Compute IS regime statistics for Z-Score signal calibration
split_idx = metrics.get("split_idx")
X_is = X.iloc[:split_idx] if split_idx else X
states_aligned = states[df.index.isin(df_aligned.index)]
split_idx = metrics.get("split_idx")
hmm_states_is = states_aligned[:len(X_is)]
regime_stats = compute_regime_stats(models, thresholds, X_is, hmm_states_is)
metrics["regime_stats"] = regime_stats
result = vectorized_backtest(
df_aligned, probabilities, states_aligned,
split_idx=split_idx,
account_size=balance,
broker=broker,
tf=tf,
prob_threshold=prob_threshold,
short_threshold=short_threshold,
regime_stats=regime_stats,
)
# Score on OOS only to prevent IS data leakage
+416
View File
@@ -0,0 +1,416 @@
"""
Shared adaptive signal evaluation logic — single source of truth for
backtesting, optimisation, and live trading.
The backtester/optimizer use :meth:`SignalEvaluator.evaluate_signal_fast` which
applies pure Z-Score logic with no live-safety gates (vectorisation-friendly).
The live bridge uses :meth:`SignalEvaluator.evaluate_signal` which adds three
sequential gates (chop-stability, transition-probability, Bollinger-Band
confluence) on top of the same Z-Score core.
This design ensures Optuna selects models based on pure signal quality while
live trading adds protective filters without invalidating the optimisation.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# ── Default Z-Score configuration ─────────────────────────────────────────────
_DEFAULT_CONFIG: Dict[str, Any] = {
# Trend-following cutoffs
"Z_CUTOFF_BULL": 1.5, # BUY when z > +1.5σ in Bull regime
"Z_CUTOFF_BEAR": -1.5, # SELL when z < −1.5σ in Bear regime
# Per-chop-state MR cutoffs (Chop_Low is easier to fade than Chop_High)
"Z_CUTOFF_CHOP_MR": {2: 2.2, 3: 2.8},
# Volatility adjustments applied when GMM cluster == 2 (high vol)
"HIGH_VOL_ADJUSTMENT": 0.3,
# Live-only MR safety gates
"MIN_CHOP_BARS_FOR_MR": 3,
"MIN_CHOP_TRANSITION_PROB": 0.70,
"MR_BB_LOWER_THRESHOLD": 0.35, # max BB position for MR_BUY
"MR_BB_UPPER_THRESHOLD": 0.65, # min BB position for MR_SELL
# Fallback fixed thresholds used when regime_stats are absent
"FALLBACK_PROB_THRESHOLD": 0.65,
"FALLBACK_SHORT_THRESHOLD": 0.35,
}
class SignalEvaluator:
"""Regime-specific Z-Score calibrated signal evaluator.
Args:
regime_stats: Mapping of HMM state_id → ``{"mean": float, "std": float,
"count": int}``. Compute on IS data via
:func:`src.engine_xgb.compute_regime_stats`.
config: Optional overrides for :data:`_DEFAULT_CONFIG`.
"""
def __init__(
self,
regime_stats: Dict[int, Dict[str, float]],
config: Optional[Dict[str, Any]] = None,
) -> None:
self.regime_stats = regime_stats or {}
self.config: Dict[str, Any] = {**_DEFAULT_CONFIG}
if config:
self.config.update(config)
# ── Core helpers ────────────────────────────────────────────────────────
def calculate_z_score(self, prob_buy: float, hmm_state: int) -> float:
"""Return Z-Score: how many σ the current probability is from the IS mean.
Falls back to mean=0.50, std=0.15 when the state has no calibration data.
"""
key = int(hmm_state)
if key in self.regime_stats:
stats = self.regime_stats[key]
mean = stats["mean"]
std = max(float(stats["std"]), 0.02)
else:
mean, std = 0.50, 0.15
return (prob_buy - mean) / std
def _vol_adjusted_cutoffs(
self, gmm_cluster: int
) -> Tuple[float, float, Dict[int, float]]:
"""Return (bull_cutoff, bear_cutoff, chop_mr_cutoffs) adjusted for vol."""
adj = self.config["HIGH_VOL_ADJUSTMENT"] if gmm_cluster == 2 else 0.0
bull = self.config["Z_CUTOFF_BULL"] + adj
bear = self.config["Z_CUTOFF_BEAR"] - adj
chop = {
s: v + adj
for s, v in self.config["Z_CUTOFF_CHOP_MR"].items()
}
return bull, bear, chop
# ── Backtester / Optimiser interface ────────────────────────────────────
def evaluate_signal_fast(
self,
prob_buy: float,
hmm_state: int,
gmm_cluster: int = 1,
) -> Tuple[Optional[str], float]:
"""Fast, gate-free evaluation for vectorised backtesting.
Returns:
``(signal_or_None, z_score)`` — signal is one of
``'BUY' | 'SELL' | 'MR_BUY' | 'MR_SELL' | None``.
"""
z = self.calculate_z_score(prob_buy, hmm_state)
bull_cut, bear_cut, chop_cuts = self._vol_adjusted_cutoffs(gmm_cluster)
if hmm_state == 0:
return ("BUY", z) if z > bull_cut else (None, z)
if hmm_state == 1:
return ("SELL", z) if z < bear_cut else (None, z)
if hmm_state in (2, 3):
cut = chop_cuts.get(hmm_state, 2.5)
if z < -cut:
return ("MR_BUY", z)
if z > cut:
return ("MR_SELL", z)
return (None, z)
# ── Live-trading interface ───────────────────────────────────────────────
def _calculate_confirmation_requirements(
self,
hmm_state: int,
z_score: float,
transition_prob: float,
gmm_cluster: int,
consec_bars: int,
just_entered_state: bool,
exited_from_state: Optional[int] = None,
) -> Dict[str, Any]:
"""Adaptive per-state confirmation requirements (bars, min-Z, etc.)."""
abs_z = abs(z_score)
high_vol = gmm_cluster == 2
_, _, chop_cuts = self._vol_adjusted_cutoffs(gmm_cluster)
req: Dict[str, Any] = {
"required_bars": 1,
"min_z_score": 0.0,
"early_entry_allowed": False,
"blocked_reason": None,
"confidence_level": "MEDIUM",
"warning": None,
}
# ── BULL (0): trend BUY ────────────────────────────────────────────
if hmm_state == 0:
bull_cut = self.config["Z_CUTOFF_BULL"] + (0.3 if high_vol else 0.0)
if abs_z >= 3.5: required_bars, conf = 1, "HIGH"
elif abs_z >= 3.0: required_bars, conf = 1, "HIGH"
elif abs_z >= 2.5: required_bars, conf = 2, "MEDIUM"
else: required_bars, conf = 3, "LOW"
if just_entered_state:
if exited_from_state == 1:
required_bars += 1; conf = "LOW"
req["warning"] = "Exited Bear — confirming Bull"
elif exited_from_state in (2, 3):
if abs_z >= 3.0 and transition_prob >= 0.75:
required_bars = max(1, required_bars - 1)
req["early_entry_allowed"] = True
req["warning"] = "Chop breakout — accelerated"
else:
required_bars += 1
if high_vol: required_bars += 1
if transition_prob >= 0.80: required_bars = max(1, required_bars - 1)
elif transition_prob < 0.65: required_bars += 1; conf = "LOW"
req["required_bars"] = min(4, max(1, required_bars))
req["min_z_score"] = bull_cut
req["confidence_level"] = conf
if consec_bars < req["required_bars"]:
req["blocked_reason"] = (
f"Bull not established: {consec_bars}/{req['required_bars']} bars"
)
elif z_score < req["min_z_score"]:
req["blocked_reason"] = (
f"Bull z={z_score:.2f} < {req['min_z_score']:.1f}"
)
return req
# ── BEAR (1): trend SELL ───────────────────────────────────────────
if hmm_state == 1:
bear_cut = abs(self.config["Z_CUTOFF_BEAR"]) + (0.3 if high_vol else 0.0)
if abs_z >= 3.5: required_bars, conf = 1, "HIGH"
elif abs_z >= 3.0: required_bars, conf = 1, "HIGH"
elif abs_z >= 2.5: required_bars, conf = 2, "MEDIUM"
else: required_bars, conf = 3, "LOW"
if just_entered_state:
if exited_from_state == 0:
required_bars += 1; conf = "LOW"
req["warning"] = "Exited Bull — confirming Bear"
elif exited_from_state in (2, 3):
if abs_z >= 3.0 and transition_prob >= 0.75:
required_bars = max(1, required_bars - 1)
req["early_entry_allowed"] = True
req["warning"] = "Chop breakdown — accelerated"
else:
required_bars += 1
if high_vol: required_bars += 1
if transition_prob >= 0.80: required_bars = max(1, required_bars - 1)
elif transition_prob < 0.65: required_bars += 1; conf = "LOW"
req["required_bars"] = min(4, max(1, required_bars))
req["min_z_score"] = bear_cut
req["confidence_level"] = conf
if consec_bars < req["required_bars"]:
req["blocked_reason"] = (
f"Bear not established: {consec_bars}/{req['required_bars']} bars"
)
elif z_score > -req["min_z_score"]:
req["blocked_reason"] = (
f"Bear z={z_score:.2f} > -{req['min_z_score']:.1f}"
)
return req
# ── CHOP_LOW (2): MR BUY ─────────────────────────────────────────
if hmm_state == 2:
base_z = chop_cuts.get(2, 2.2)
if abs_z >= 3.5: required_bars, conf = 1, "HIGH"
elif abs_z >= 3.0: required_bars, conf = 1, "HIGH"
elif abs_z >= 2.8: required_bars, conf = 2, "MEDIUM"
else: required_bars, conf = 3, "LOW"
if just_entered_state:
if exited_from_state == 1:
if abs_z >= 2.5:
required_bars = max(1, required_bars - 1)
req["warning"] = "Natural Bear→Chop_Low — accelerated"
elif exited_from_state == 0:
required_bars += 2; conf = "LOW"
req["warning"] = "Suspicious Bull→Chop_Low — extra confirmation"
if high_vol: required_bars += 1
if transition_prob >= 0.80: required_bars = max(1, required_bars - 1)
elif transition_prob < 0.65: required_bars += 1
req["required_bars"] = min(4, max(1, required_bars))
req["min_z_score"] = base_z
req["confidence_level"] = conf
if consec_bars < req["required_bars"]:
req["blocked_reason"] = (
f"Chop_Low not stable: {consec_bars}/{req['required_bars']} bars"
)
elif abs_z < req["min_z_score"]:
req["blocked_reason"] = (
f"|z|={abs_z:.2f} < {req['min_z_score']:.1f}"
)
return req
# ── CHOP_HIGH (3): MR SELL ────────────────────────────────────────
if hmm_state == 3:
base_z = chop_cuts.get(3, 2.8)
if abs_z >= 4.0: required_bars, conf = 1, "HIGH"
elif abs_z >= 3.5: required_bars, conf = 2, "MEDIUM"
elif abs_z >= 3.0: required_bars, conf = 3, "MEDIUM"
else: required_bars, conf = 4, "LOW"
if just_entered_state:
if exited_from_state == 0:
if abs_z >= 3.0:
required_bars = max(1, required_bars - 1)
req["warning"] = "Natural Bull→Chop_High — accelerated"
elif exited_from_state == 1:
required_bars += 2; conf = "LOW"
req["warning"] = "Suspicious Bear→Chop_High — extra confirmation"
if high_vol: required_bars += 1
if transition_prob >= 0.80: required_bars = max(1, required_bars - 1)
elif transition_prob < 0.65: required_bars += 1
req["required_bars"] = min(5, max(1, required_bars))
req["min_z_score"] = base_z
req["confidence_level"] = conf
if consec_bars < req["required_bars"]:
req["blocked_reason"] = (
f"Chop_High not stable: {consec_bars}/{req['required_bars']} bars"
)
elif abs_z < req["min_z_score"]:
req["blocked_reason"] = (
f"|z|={abs_z:.2f} < {req['min_z_score']:.1f}"
)
return req
req["blocked_reason"] = f"Unknown HMM state {hmm_state}"
return req
def evaluate_signal(
self,
prob_buy: float,
hmm_state: int,
gmm_cluster: int,
stability: Dict[str, Any],
bb_position: float = 0.5,
transition_prob: Optional[float] = None,
) -> Dict[str, Any]:
"""Full evaluation with all live safety gates.
Args:
prob_buy: XGBoost BUY-class probability.
hmm_state: Current HMM regime (0=Bull, 1=Bear, 2=Chop_Low, 3=Chop_High).
gmm_cluster: Volatility cluster (0=low, 1=med, 2=high).
stability: Dict from :func:`build_stability` tracking consecutive
bars, regime changes, etc.
bb_position: Price position within Bollinger Bands (0=lower, 1=upper).
transition_prob: HMM self-transition probability P(stay in current state).
Returns:
``{"signal", "confidence", "reason", "gate_results", "confidence_level"}``
"""
result: Dict[str, Any] = {
"signal": None,
"confidence": 0.0,
"reason": "",
"gate_results": {},
"confidence_level": "MEDIUM",
}
z = self.calculate_z_score(prob_buy, hmm_state)
result["confidence"] = round(z, 3)
abs_z = abs(z)
is_chop = hmm_state in (2, 3)
consec = stability.get("consecutive_bars", 1)
just_entered = stability.get("just_entered_state", False)
exited_from = stability.get("exited_from_state", None)
t_prob = transition_prob if transition_prob is not None else 0.70
# ── Universal confirmation gate ────────────────────────────────────
req = self._calculate_confirmation_requirements(
hmm_state=hmm_state,
z_score=z,
transition_prob=t_prob,
gmm_cluster=gmm_cluster,
consec_bars=consec,
just_entered_state=just_entered,
exited_from_state=exited_from,
)
result["confidence_level"] = req["confidence_level"]
if req["blocked_reason"]:
result["reason"] = req["blocked_reason"]
result["gate_results"]["confirmation_failed"] = True
return result
result["gate_results"]["confirmation_passed"] = True
# ── Transition-probability gate (chop only) ────────────────────────
if is_chop and t_prob < self.config["MIN_CHOP_TRANSITION_PROB"]:
result["reason"] = (
f"Chop unstable: P(stay)={t_prob:.2f} "
f"< {self.config['MIN_CHOP_TRANSITION_PROB']}"
)
result["gate_results"]["transition_failed"] = True
return result
if is_chop:
result["gate_results"]["transition_passed"] = True
# ── Bollinger Band confluence gate (chop only) ─────────────────────
_, _, chop_cuts = self._vol_adjusted_cutoffs(gmm_cluster)
if is_chop:
cut = chop_cuts.get(hmm_state, 2.5)
if z < -cut:
# Potential MR_BUY — price must be near lower band
if bb_position > self.config["MR_BB_LOWER_THRESHOLD"]:
result["reason"] = (
f"MR_BUY blocked: BB {bb_position:.2f} "
f"> {self.config['MR_BB_LOWER_THRESHOLD']:.2f}"
)
result["gate_results"]["bb_failed"] = True
return result
result["signal"] = "MR_BUY"
result["gate_results"]["bb_passed"] = True
result["reason"] = (
f"Chop_Low MR_BUY: |z|={abs_z:.2f}>{cut:.1f} "
f"({consec} bars, {req['confidence_level']})"
)
elif z > cut:
# Potential MR_SELL — price must be near upper band
if bb_position < self.config["MR_BB_UPPER_THRESHOLD"]:
result["reason"] = (
f"MR_SELL blocked: BB {bb_position:.2f} "
f"< {self.config['MR_BB_UPPER_THRESHOLD']:.2f}"
)
result["gate_results"]["bb_failed"] = True
return result
result["signal"] = "MR_SELL"
result["gate_results"]["bb_passed"] = True
result["reason"] = (
f"Chop_High MR_SELL: |z|={abs_z:.2f}>{cut:.1f} "
f"({consec} bars, {req['confidence_level']})"
)
else:
result["reason"] = (
f"Chop: |z|={abs_z:.2f} < {cut:.1f}"
)
# ── Trend states ───────────────────────────────────────────────────
elif hmm_state == 0:
result["signal"] = "BUY"
result["reason"] = (
f"Bull BUY: z={z:.2f}>{req['min_z_score']:.1f} "
f"({consec} bars, {req['confidence_level']})"
)
elif hmm_state == 1:
result["signal"] = "SELL"
result["reason"] = (
f"Bear SELL: z={z:.2f}<-{req['min_z_score']:.1f} "
f"({consec} bars, {req['confidence_level']})"
)
if req.get("warning"):
result["reason"] += f" [{req['warning']}]"
return result
+2 -3
View File
@@ -182,7 +182,7 @@ def run_validation(
"Run --mode train --tf %s --broker %s to create a dedicated model.", tf, broker, xgb_path, tf, broker
)
try:
models_xgb, thresholds_xgb, _ = load_xgb_ensemble(xgb_path)
models_xgb, thresholds_xgb, xgb_meta = load_xgb_ensemble(xgb_path)
except FileNotFoundError:
raise FileNotFoundError(
"XGB ensemble not found at models/xgb_ensemble.pkl. "
@@ -211,8 +211,7 @@ def run_validation(
account_size=account_size,
broker=broker,
tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
regime_stats=xgb_meta.get("regime_stats"),
)
sharpe = result["sharpe_ratio"]