mirror of
https://github.com/lucasmos/GoldRegime_X.git
synced 2026-11-02 06:26:15 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user