feat: Enhance signal engine and reporting capabilities

- Updated signal engine logic to improve trade decision-making with new probability thresholds and dynamic gates.
- Introduced new `cpcv_reporting.py` module for generating comprehensive CPCV reports, including optimization summaries and performance metrics.
- Added `optuna_param_guard.py` to enforce parameter validation for Optuna optimization, ensuring only approved parameters are used.
- Created `regime_diagnostics.py` for summarizing regime statistics and trade distributions, facilitating better analysis of trading strategies.
This commit is contained in:
Luke Mosoti
2026-05-31 13:51:13 +03:00
parent c1dbf315c8
commit 392e408c11
14 changed files with 1425 additions and 506 deletions
+161 -42
View File
@@ -21,15 +21,16 @@ except ImportError:
from src.logger import setup_logger, reconfigure_for_tf
from src.processor import process_pipeline, TF_CONFIG, PROCESSED_PATH, save_feature_scaler, load_feature_scaler
from src.engine_hmm import fit_hmm, save_model as save_hmm, load_model as load_hmm, get_model_path as hmm_model_path
from src.engine_hmm import fit_hmm, save_model as save_hmm, load_model as load_hmm, get_model_path as hmm_model_path, STATE_NAMES
from src.engine_xgb import (
prepare_features, train_xgb, get_predictions,
export_onnx, save_xgb, load_xgb, ONNX_PATH, FEATURE_COLS,
train_xgb_ensemble, get_predictions_ensemble,
train_xgb_ensemble,
save_xgb_ensemble, load_xgb_ensemble, export_onnx_ensemble, ENSEMBLE_PKL_PATH,
get_ensemble_path, TF_TRAIN_RATIO, compute_regime_stats,
train_regime_models, get_regime_predictions,
save_regime_models, load_regime_models, TB_CONFIG,
regime_first_probabilities,
)
from src.optimizer import (
run_optimization, get_best_params, _score_result as _calc_score,
@@ -40,6 +41,7 @@ from src.optimizer import (
resolve_n_states as _optimizer_resolve_n_states,
)
from src.backtester import vectorized_backtest, format_payout
from src.regime_diagnostics import write_regime_diagnostics
from src.visualizer import generate_full_report
from src.risk_manager import AdaptiveRiskManager
@@ -68,6 +70,32 @@ def resolve_n_states(tf: str, params: dict) -> int:
return 3
def _extract_cpcv_metrics(cpcv_json: dict) -> dict:
"""Extract key CPCV metrics from unified schema or legacy flat payload."""
agg = cpcv_json.get("cpcv_aggregate_stats") if isinstance(cpcv_json, dict) else None
if isinstance(agg, dict):
return {
"score": float(agg.get("cpcv_score", cpcv_json.get("final_score_and_penalties", {}).get("final_score", 0.0))),
"n_valid_paths": int(agg.get("n_valid_paths", 0)),
"median_sharpe": float(agg.get("median_sharpe", 0.0)),
"median_trades": int(agg.get("median_trades", 0)),
"median_win_rate": float(agg.get("median_win_rate", 0.0)),
"median_drawdown": float(agg.get("median_drawdown", 0.0)),
"median_return": float(agg.get("median_return", 0.0)),
"std_sharpe": float(agg.get("std_sharpe", 0.0)),
}
return {
"score": float(cpcv_json.get("cpcv_score", 0.0)),
"n_valid_paths": int(cpcv_json.get("n_valid_paths", 0)),
"median_sharpe": float(cpcv_json.get("median_sharpe", 0.0)),
"median_trades": int(cpcv_json.get("median_trades", 0)),
"median_win_rate": float(cpcv_json.get("median_win_rate", 0.0)),
"median_drawdown": float(cpcv_json.get("median_drawdown", 0.0)),
"median_return": float(cpcv_json.get("median_return", 0.0)),
"std_sharpe": float(cpcv_json.get("std_sharpe", 0.0)),
}
_M5_EXPIRY_HOURS = 120 # 5 days
# Staleness thresholds per TF (days). If the saved model is older than this
@@ -137,6 +165,13 @@ def _train_for_tf(tf: str, balance: float, broker: str, params: dict):
model_hmm, states, state_map = fit_hmm(
df, n_states=resolve_n_states(tf, params), tf=tf # fix: tf= was missing before
)
write_regime_diagnostics(
tf=tf,
states=states,
returns=df["log_return"].values[: len(states)],
state_labels=STATE_NAMES,
output_dir="reports",
)
X, y, df_aligned, feature_scaler = prepare_features(df, states, tf=tf)
save_feature_scaler(feature_scaler, tf=tf, broker=broker)
@@ -748,10 +783,13 @@ def cmd_report(args):
if _hmm_path is None:
raise FileNotFoundError
model_hmm = load_hmm(_hmm_path)
from src.engine_hmm import predict_states, STATE_NAMES_3, STATE_NAMES_2, STATE_NAMES_4
from src.engine_hmm import predict_states
if int(getattr(model_hmm, "n_components", 0)) != 3:
raise ValueError(
f"Non-canonical HMM artifact for {tf}/{broker}: n_components={getattr(model_hmm, 'n_components', None)}"
)
states = predict_states(model_hmm, df)
n = model_hmm.n_components
state_names = {2: STATE_NAMES_2, 3: STATE_NAMES_3, 4: STATE_NAMES_4}.get(n, STATE_NAMES_3)
state_names = STATE_NAMES
except Exception:
model_hmm, states, state_names = fit_hmm(df, n_states=resolve_n_states(tf, params))
@@ -763,16 +801,28 @@ def cmd_report(args):
X, y, df_aligned, _ = prepare_features(df, states, feature_scaler=_feat_scaler, tf=tf)
states_aligned = states[df.index.isin(df_aligned.index)]
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
_xgb_path = get_ensemble_path(tf, broker)
if not _xgb_path.exists():
_xgb_path = ENSEMBLE_PKL_PATH
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
# If the saved ensemble was trained with different features (e.g. before DXY
# was added), retrain in-memory so the report stays consistent with the data.
if metrics.get("feature_cols") != list(X.columns):
raise ValueError("Feature mismatch — retraining for report.")
except (FileNotFoundError, ValueError):
models_xgb = None
thresholds_xgb = None
metrics = {}
if _xgb_path.exists():
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
if metrics.get("feature_cols") and metrics.get("feature_cols") != list(X.columns):
logger.warning("Feature mismatch in saved ensemble for %s/%s; ignoring ensemble artifact.", tf, broker)
models_xgb, thresholds_xgb, metrics = None, None, {}
except Exception as _e:
logger.warning("Could not load ensemble artifact for %s/%s: %s", tf, broker, _e)
models_xgb, thresholds_xgb, metrics = None, None, {}
if trend_model is None and shock_model is None and (models_xgb is None or thresholds_xgb is None):
models_xgb, thresholds_xgb, metrics = train_xgb_ensemble(
X, y,
max_depth=params.get("max_depth", 4),
@@ -785,11 +835,21 @@ def cmd_report(args):
reg_alpha=params.get("reg_alpha", 0.1),
)
_, probabilities = get_predictions_ensemble(models_xgb, thresholds_xgb, X)
states_aligned = states[df.index.isin(df_aligned.index)]
probabilities, prob_source = regime_first_probabilities(
X=X,
states=states_aligned,
tf=tf,
broker=broker,
trend_model=trend_model,
shock_model=shock_model,
ensemble_models=models_xgb,
ensemble_thresholds=thresholds_xgb,
fallback_prob=0.50,
)
logger.info("Probability source for %s/%s: %s", tf, broker, prob_source)
# Ensure regime_stats are present (may be absent in pre-Z-Score saved models)
if not metrics.get("regime_stats"):
if models_xgb is not None and thresholds_xgb is not None and 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)]
@@ -822,16 +882,23 @@ def cmd_report(args):
if _cpcv_json.exists():
try:
_cpcv = json.loads(_cpcv_json.read_text())
result["cpcv_score"] = _cpcv.get("cpcv_score", 0.0)
result["cpcv_n_valid_paths"] = _cpcv.get("n_valid_paths", 0)
result["oos_sharpe_ratio"] = _cpcv.get("median_sharpe", 0.0)
result["oos_n_trades"] = _cpcv.get("median_trades", 0)
result["oos_win_rate"] = _cpcv.get("median_win_rate", 0.0)
result["oos_max_drawdown"] = _cpcv.get("median_drawdown", 0.0)
result["oos_floating_max_drawdown"] = _cpcv.get("median_drawdown", 0.0)
result["oos_total_return"] = _cpcv.get("median_return", 0.0)
result["cpcv_path_scores"] = _cpcv.get("path_scores", [])
result["cpcv_std_sharpe"] = _cpcv.get("std_sharpe", 0.0)
_metrics = _extract_cpcv_metrics(_cpcv)
_agg = _cpcv.get("cpcv_aggregate_stats") if isinstance(_cpcv, dict) else None
_paths = _cpcv.get("per_path_fold_stats") if isinstance(_cpcv, dict) else None
result["cpcv_score"] = _metrics["score"]
result["cpcv_n_valid_paths"] = _metrics["n_valid_paths"]
result["oos_sharpe_ratio"] = _metrics["median_sharpe"]
result["oos_n_trades"] = _metrics["median_trades"]
result["oos_win_rate"] = _metrics["median_win_rate"]
result["oos_max_drawdown"] = _metrics["median_drawdown"]
result["oos_floating_max_drawdown"] = _metrics["median_drawdown"]
result["oos_total_return"] = _metrics["median_return"]
result["cpcv_std_sharpe"] = _metrics["std_sharpe"]
if isinstance(_agg, dict):
result["cpcv_path_scores"] = (_paths or {}).get("path_scores", [])
else:
result["cpcv_path_scores"] = _cpcv.get("path_scores", [])
logger.info(
"Loaded CPCV OOS stats from %s — median_sharpe=%.3f median_trades=%d",
_cpcv_json, result["oos_sharpe_ratio"], result["oos_n_trades"],
@@ -996,6 +1063,10 @@ def cmd_sensitivity(args):
if _hmm_path is None:
raise FileNotFoundError
model_hmm = load_hmm(_hmm_path)
if int(getattr(model_hmm, "n_components", 0)) != 3:
raise ValueError(
f"Non-canonical HMM artifact for {tf}/{broker}: n_components={getattr(model_hmm, 'n_components', None)}"
)
from src.engine_hmm import predict_states
states = predict_states(model_hmm, df)
except Exception:
@@ -1009,21 +1080,43 @@ def cmd_sensitivity(args):
X, y, df_aligned, _ = prepare_features(df, states, feature_scaler=_feat_scaler, tf=tf)
states_aligned = states[df.index.isin(df_aligned.index)]
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
_xgb_path = get_ensemble_path(tf, broker)
if not _xgb_path.exists():
_xgb_path = ENSEMBLE_PKL_PATH
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
if metrics.get("feature_cols") != list(X.columns):
raise ValueError("Feature mismatch")
except (FileNotFoundError, ValueError):
logger.warning("No saved ensemble found for %s/%s — can't run sensitivity.", tf, broker)
models_xgb = None
thresholds_xgb = None
metrics = {}
if _xgb_path.exists():
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
if metrics.get("feature_cols") and metrics.get("feature_cols") != list(X.columns):
logger.warning("Feature mismatch in saved ensemble for %s/%s; ignoring ensemble artifact.", tf, broker)
models_xgb, thresholds_xgb, metrics = None, None, {}
except Exception as _e:
logger.warning("Could not load ensemble artifact for %s/%s: %s", tf, broker, _e)
models_xgb, thresholds_xgb, metrics = None, None, {}
if trend_model is None and shock_model is None and (models_xgb is None or thresholds_xgb is None):
logger.warning("No saved regime models or ensemble found for %s/%s — can't run sensitivity.", tf, broker)
return
_, probabilities = get_predictions_ensemble(models_xgb, thresholds_xgb, X)
states_aligned = states[df.index.isin(df_aligned.index)]
probabilities, prob_source = regime_first_probabilities(
X=X,
states=states_aligned,
tf=tf,
broker=broker,
trend_model=trend_model,
shock_model=shock_model,
ensemble_models=models_xgb,
ensemble_thresholds=thresholds_xgb,
fallback_prob=0.50,
)
logger.info("Probability source for %s/%s: %s", tf, broker, prob_source)
if not metrics.get("regime_stats"):
if models_xgb is not None and thresholds_xgb is not None and not metrics.get("regime_stats"):
_rs_split = metrics.get("split_idx") or int(len(X) * 0.8)
metrics["regime_stats"] = compute_regime_stats(
models_xgb, thresholds_xgb,
@@ -1042,7 +1135,7 @@ def cmd_sensitivity(args):
probabilities=probabilities,
states_aligned=states_aligned,
split_idx=split_idx,
regime_stats=metrics["regime_stats"],
regime_stats=metrics.get("regime_stats", {}),
)
@@ -1089,6 +1182,10 @@ def cmd_montecarlo(args):
_hmm_path = hmm_model_path(tf, broker)
try:
model_hmm = load_hmm(_hmm_path)
if int(getattr(model_hmm, "n_components", 0)) != 3:
raise ValueError(
f"Non-canonical HMM artifact for {tf}/{broker}: n_components={getattr(model_hmm, 'n_components', None)}"
)
from src.engine_hmm import predict_states
states = predict_states(model_hmm, df)
except Exception:
@@ -1100,15 +1197,26 @@ def cmd_montecarlo(args):
_feat_scaler = None
X, y, df_aligned, _ = prepare_features(df, states, feature_scaler=_feat_scaler, tf=tf)
states_aligned = states[df.index.isin(df_aligned.index)]
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
_xgb_path = get_ensemble_path(tf, broker)
if not _xgb_path.exists():
_xgb_path = ENSEMBLE_PKL_PATH
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
if metrics.get("feature_cols") != list(X.columns):
raise ValueError("Feature mismatch")
except (FileNotFoundError, ValueError):
models_xgb = None
thresholds_xgb = None
metrics = {}
if _xgb_path.exists():
try:
models_xgb, thresholds_xgb, metrics = load_xgb_ensemble(_xgb_path)
if metrics.get("feature_cols") and metrics.get("feature_cols") != list(X.columns):
logger.warning("Feature mismatch in saved ensemble for %s/%s; ignoring ensemble artifact.", tf, broker)
models_xgb, thresholds_xgb, metrics = None, None, {}
except Exception as _e:
logger.warning("Could not load ensemble artifact for %s/%s: %s", tf, broker, _e)
models_xgb, thresholds_xgb, metrics = None, None, {}
if trend_model is None and shock_model is None and (models_xgb is None or thresholds_xgb is None):
models_xgb, thresholds_xgb, metrics = train_xgb_ensemble(
X, y,
max_depth=params.get("max_depth", 4),
@@ -1121,8 +1229,19 @@ def cmd_montecarlo(args):
reg_alpha=params.get("reg_alpha", 0.1),
)
_, probabilities = get_predictions_ensemble(models_xgb, thresholds_xgb, X)
states_aligned = states[df.index.isin(df_aligned.index)]
probabilities, prob_source = regime_first_probabilities(
X=X,
states=states_aligned,
tf=tf,
broker=broker,
trend_model=trend_model,
shock_model=shock_model,
ensemble_models=models_xgb,
ensemble_thresholds=thresholds_xgb,
fallback_prob=0.50,
)
logger.info("Probability source for %s/%s: %s", tf, broker, prob_source)
split_idx = metrics.get("split_idx")
if split_idx is not None and not (0 < split_idx < len(X)):
split_idx = int(len(X) * 0.8)
-37
View File
@@ -1,37 +0,0 @@
{
"tf": "H1",
"n_states": 4,
"taxonomy": "TREND/MEAN_REVERSION/VOLATILITY_SHOCK",
"states": {
"0": {
"label": "TREND",
"raw_id": 0,
"persistence": 0.93728,
"entropy_bits": 0.396561,
"vol_mean_scaled": -0.570834,
"return_mean_scaled": 0.012574,
"return_dispersion": 0.501088,
"tradeable": true
},
"1": {
"label": "MEAN_REVERSION",
"raw_id": 3,
"persistence": 0.923706,
"entropy_bits": 0.474988,
"vol_mean_scaled": 0.405601,
"return_mean_scaled": 0.081033,
"return_dispersion": 1.125372,
"tradeable": false
},
"2": {
"label": "VOLATILITY_SHOCK",
"raw_id": 2,
"persistence": 0.949264,
"entropy_bits": 0.303693,
"vol_mean_scaled": 2.319465,
"return_mean_scaled": -0.592517,
"return_dispersion": 2.299725,
"tradeable": true
}
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"tf": "M15",
"n_states": 3,
"taxonomy": "TREND/MEAN_REVERSION/VOLATILITY_SHOCK",
"states": {
"0": {
"label": "TREND",
"raw_id": 0,
"persistence": 0.982026,
"entropy_bits": 0.134919,
"vol_mean_scaled": -0.510343,
"return_mean_scaled": -0.008886,
"return_dispersion": 0.356379,
"tradeable": true
},
"1": {
"label": "MEAN_REVERSION",
"raw_id": 1,
"persistence": 0.970884,
"entropy_bits": 0.215877,
"vol_mean_scaled": 0.037283,
"return_mean_scaled": 0.046802,
"return_dispersion": 0.745696,
"tradeable": false
},
"2": {
"label": "VOLATILITY_SHOCK",
"raw_id": 2,
"persistence": 0.971527,
"entropy_bits": 0.18707,
"vol_mean_scaled": 1.533141,
"return_mean_scaled": -0.105524,
"return_dispersion": 2.249321,
"tradeable": true
}
}
}
Binary file not shown.
Binary file not shown.
+46 -17
View File
@@ -2,7 +2,7 @@ import numpy as np
import pandas as pd
from src.logger import setup_logger
from src.risk_manager import BROKER_CONFIGS
from src.engine_hmm import CANONICAL_REGIME_ID, REGIME_TREND, REGIME_MR, REGIME_SHOCK
from src.engine_hmm import CANONICAL_REGIME_ID, REGIME_TREND, REGIME_MR, REGIME_SHOCK, STATE_NAMES
from src.trade_lifecycle import config_for_tf
logger = setup_logger(__name__)
@@ -398,6 +398,37 @@ def _mr_attribution(
}
def _regime_occupancy(states: np.ndarray) -> dict:
states_arr = np.asarray(states)
n = int(len(states_arr))
if n == 0:
return {STATE_NAMES[i]: 0.0 for i in sorted(STATE_NAMES)}
out: dict[str, float] = {}
for sid, label in sorted(STATE_NAMES.items()):
out[label] = float(np.mean(states_arr == sid)) * 100.0
return out
def _trade_distribution(signals: np.ndarray, states: np.ndarray) -> dict:
sig = np.asarray(signals)
st = np.asarray(states)
n = int(min(len(sig), len(st)))
out = {STATE_NAMES[i]: 0 for i in sorted(STATE_NAMES)}
if n == 0:
return out
sig = sig[:n]
st = st[:n]
prev_s = np.concatenate([[0], sig[:-1]])
is_entry = (sig != 0) & (sig != prev_s)
for idx in np.where(is_entry)[0]:
sid = int(st[idx])
label = STATE_NAMES.get(sid, f"state_{sid}")
out[label] = int(out.get(label, 0) + 1)
return out
def _compute_bb_positions(close: np.ndarray, window: int = 20) -> np.ndarray:
"""Normalised position within rolling high-low band (0=at low, 1=at high)."""
out = np.full(len(close), 0.5)
@@ -422,7 +453,7 @@ def _run_bar_loop(
raw_atr_arr=None,
z_cutoff_bull: float | None = None,
z_cutoff_bear: float | None = None,
h1_entry_prob: float | None = None,
entry_probability_threshold: float | None = None,
) -> tuple:
from src.signal_engine import SignalEngine
@@ -461,7 +492,8 @@ def _run_bar_loop(
"tf": tf, "hmm_transmat": hmm_transmat,
"z_cutoff_bull": z_cutoff_bull,
"z_cutoff_bear": z_cutoff_bear,
"h1_entry_prob": h1_entry_prob,
"entry_probability_threshold": entry_probability_threshold,
"h1_entry_prob": entry_probability_threshold,
}
# ── Raw ATR fallback (if not pre-computed by caller) ─────────────────────
@@ -609,12 +641,6 @@ def _run_bar_loop(
# ── 6. End-of-bar exit checks ─────────────────────────────────────
hit_time_stop = bars_in_trade >= MAX_HOLD_BARS
_hmm_now = int(states[i])
regime_exit = (
(signal_type == "trend" and _hmm_now >= CHOP_STATE) or
(signal_type == "mean_reversion" and _hmm_now < CHOP_STATE)
)
# Policy exit decision via evaluate_bar (Phase 2).
# pos_ctx carries end-of-bar P&L so profit_erosion check is accurate.
_pos_ctx = {
@@ -622,7 +648,7 @@ def _run_bar_loop(
"max_fav_pnl": max_fav_pnl, "bars_in_trade": bars_in_trade,
}
_row_ctx_exit = {
"hmm_state": _hmm_now, "prob": current_prob,
"hmm_state": int(states[i]), "prob": current_prob,
"synth_vix_zscore": float(vix_zscore_arr[i]),
"atr_band_position": float(atr_band_arr[i]),
}
@@ -642,7 +668,7 @@ def _run_bar_loop(
and (i + 1 >= n or not bool(test_mask[i + 1]))
)
exit_now = hit_sl or hit_tp1 or hit_time_stop or regime_exit or engine_exit or _scalp_hit or _force_exit
exit_now = hit_sl or hit_tp1 or hit_time_stop or engine_exit or _scalp_hit or _force_exit
if exit_now:
costs_arr[i] += spread_cost * size_mult
@@ -660,7 +686,6 @@ def _run_bar_loop(
"exit_reason": "SL" if hit_sl else
"TP" if hit_tp1 else
"TIME" if hit_time_stop else
"REGIME" if regime_exit else
"SCALP" if _scalp_hit else
_exit_reason if engine_exit else "FORCE",
})
@@ -673,7 +698,7 @@ def _run_bar_loop(
engine.on_trade_closed()
cum_return, max_fav_pnl, bars_in_trade = 0.0, 0.0, 0
floating_pnl_usd = 0.0
if regime_exit:
if _exit_reason == "regime_reversal":
_lc_regime_forced_closes += 1
else:
strategy_returns[i] = bar_gross
@@ -715,13 +740,13 @@ def _run_bar_loop(
signals[i] = direction
sizes_arr[i] = size_mult
trade_entry_i = i
signal_type = "mean_reversion" if "mr" in _bar_decision.reason else "trend"
signal_type = "shock" if _bar_decision.regime == REGIME_SHOCK else "trend"
# ── Raw ATR-based SL/TP at entry ─────────────────────────
entry_price = float(closes[i])
entry_atr = max(float(raw_atr_arr[i]), 1e-6)
_regime_key = "chop" if int(states[i]) >= CHOP_STATE else "trend"
_regime_key = "chop" if _bar_decision.regime == REGIME_SHOCK else "trend"
_sl_tp = _tp_sl_cfg.get(_regime_key, _tp_sl_cfg["trend"])
sl_dist = _sl_tp["sl_mult"] * entry_atr
tp1_dist = _sl_tp["tp1_mult"] * entry_atr
@@ -792,7 +817,9 @@ def vectorized_backtest(
# Extract optional Z overrides from evaluator_config for sensitivity sweeps.
z_bull = evaluator_config.get("Z_CUTOFF_BULL") if evaluator_config else None
z_bear = evaluator_config.get("Z_CUTOFF_BEAR") if evaluator_config else None
h1_ep = evaluator_config.get("h1_entry_prob") if evaluator_config else None
ep_thr = None
if evaluator_config:
ep_thr = evaluator_config.get("entry_probability_threshold", evaluator_config.get("h1_entry_prob"))
atr_norm = df["atr_normalized"].values
n = len(df)
@@ -840,7 +867,7 @@ def vectorized_backtest(
split_idx=split_idx, return_trades=return_trades,
test_mask=test_mask, raw_atr_arr=raw_atr_arr,
z_cutoff_bull=z_bull, z_cutoff_bear=z_bear,
h1_entry_prob=h1_ep,
entry_probability_threshold=ep_thr,
)
_spread_frac = BROKER_CONFIGS.get(broker, BROKER_CONFIGS["standard"]).get("spread_frac", 0.0004)
@@ -863,6 +890,8 @@ def vectorized_backtest(
result.update(_lc_events)
# Full-period MR attribution
result.update(_mr_attribution(signals, hmm_states, strategy_returns))
result["regime_occupancy"] = _regime_occupancy(hmm_states)
result["trade_distribution"] = _trade_distribution(signals, hmm_states)
if split_idx is not None and 0 < split_idx < len(strategy_returns) and test_mask is None:
is_m = _compute_metrics(strategy_returns[:split_idx], signals[:split_idx], tf)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from pathlib import Path
def build_cpcv_report(
tf: str,
broker: str,
optimization_summary: dict,
cpcv_result: dict,
objective_breakdown: dict,
final_score: float,
penalties: dict,
) -> dict:
tf_up = tf.upper()
return {
"schema_version": "1.0",
"timeframe": tf_up,
"broker": broker,
"optimization_summary": {
"study_name": optimization_summary.get("study_name"),
"best_trial": optimization_summary.get("best_trial"),
"completed_trials": optimization_summary.get("completed_trials"),
"search_space": optimization_summary.get("search_space", []),
},
"cpcv_aggregate_stats": {
"cpcv_score": float(cpcv_result.get("cpcv_score", 0.0)),
"n_valid_paths": int(cpcv_result.get("n_valid_paths", 0)),
"median_sharpe": float(cpcv_result.get("median_sharpe", 0.0)),
"std_sharpe": float(cpcv_result.get("std_sharpe", 0.0)),
"median_trades": int(cpcv_result.get("median_trades", 0)),
"median_win_rate": float(cpcv_result.get("median_win_rate", 0.0)),
"median_drawdown": float(cpcv_result.get("median_drawdown", 0.0)),
"median_return": float(cpcv_result.get("median_return", 0.0)),
"median_pf": float(cpcv_result.get("median_pf", 1.0)),
"median_expectancy": float(cpcv_result.get("median_expectancy", 0.0)),
"median_calmar": float(cpcv_result.get("median_calmar", 0.0)),
},
"per_path_fold_stats": {
"path_scores": list(cpcv_result.get("path_scores", [])),
"path_sharpes": list(cpcv_result.get("path_sharpes", [])),
"path_trades": list(cpcv_result.get("path_trades", [])),
"path_winrates": list(cpcv_result.get("path_winrates", [])),
"path_drawdowns": list(cpcv_result.get("path_drawdowns", [])),
"path_returns": list(cpcv_result.get("path_returns", [])),
"path_profit_factors": list(cpcv_result.get("path_pfs", [])),
"path_expectancies": list(cpcv_result.get("path_expectancies", [])),
},
"regime_occupancy": dict(cpcv_result.get("regime_occupancy", {})),
"trade_distribution": dict(cpcv_result.get("trade_distribution", {})),
"lifecycle_telemetry": {
"total_mr_leaks": int(cpcv_result.get("total_mr_leaks", 0)),
"total_activation_events": int(cpcv_result.get("total_activation_events", 0)),
"total_partial_close_events": int(cpcv_result.get("total_partial_close_events", 0)),
"total_regime_forced_closes": int(cpcv_result.get("total_regime_forced_closes", 0)),
"total_trail_updates": int(cpcv_result.get("total_trail_updates", 0)),
"avg_activation_pnl_usd": float(cpcv_result.get("avg_activation_pnl_usd", 0.0)),
},
"objective_contribution_breakdown": {
"component_values": dict(objective_breakdown.get("components", {})),
"component_weights": dict(objective_breakdown.get("weights", {})),
"base_score": float(objective_breakdown.get("base_score", 0.0)),
},
"final_score_and_penalties": {
"final_score": float(final_score),
"penalties": {k: float(v) for k, v in penalties.items()},
"total_penalty": float(sum(float(v) for v in penalties.values())),
},
}
def write_cpcv_report(
report: dict,
tf: str,
output_dir: str | Path = "reports",
) -> Path:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"cpcv_{tf.upper()}.json"
path.write_text(json.dumps(report, indent=2), encoding="utf-8")
return path
def write_legacy_cpcv_report(
report: dict,
tf: str,
broker: str,
output_dir: str | Path = "reports",
) -> Path:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"cpcv_{tf.lower()}_{broker}.json"
path.write_text(json.dumps(report, indent=2), encoding="utf-8")
return path
+79 -65
View File
@@ -97,27 +97,19 @@ def _classify_hmm_states(
) -> tuple[np.ndarray, dict, dict]:
"""Map raw HMM state IDs to canonical TREND / MEAN_REVERSION / VOLATILITY_SHOCK IDs.
Classification is deterministic from per-state statistics so different IS
windows always produce semantically consistent labels, eliminating the
return-sorted Bull/Bear/Chop ordering that was sensitive to mean-return
magnitude.
Priority order:
1. SHOCK: vol_mean > _SHOCK_VOL_THR AND ret_disp > _SHOCK_DISP_THR
2. TREND: persistence > _TREND_PERSIST AND entropy < _TREND_ENTROPY
3. MR: all remaining states
Tie-breaking when no state meets a hard criterion:
- No SHOCK found: state with highest (vol_mean + ret_disp) → SHOCK
- No TREND found: state with highest persistence (among non-SHOCK) → TREND
- Remaining: MEAN_REVERSION (includes all extra states in 4-state HMMs)
State labels are assigned independently from per-state descriptors, with no
forced one-of-each constraint. This allows valid outcomes such as:
- multiple TREND states,
- multiple SHOCK states,
- no MEAN_REVERSION state in a specific fit.
Returns:
remapped — int32 array aligned with raw_states using canonical IDs
remapped — int32 array aligned with raw_states using canonical IDs
state_names — {canonical_id: label}
remap — {raw_state_id: canonical_id} stored on model as _regime_remap
"""
eps = 1e-12
raw_counts = {int(i): int(np.sum(raw_states == i)) for i in range(n_states)}
per_state: dict[int, dict] = {}
for i in range(n_states):
pers = float(model.transmat_[i, i])
@@ -125,70 +117,81 @@ def _classify_hmm_states(
ent = float(-np.sum(row * np.log2(row)))
vol_m = float(model.means_[i, 1])
ret_d = float(np.sqrt(max(float(model.covars_[i, 0, 0]), 0.0)))
# Volatility proxy combines latent vol feature mean and return dispersion.
vol_p = float(max(abs(vol_m), ret_d))
per_state[i] = {
"persistence": pers, "entropy": ent,
"vol_mean": vol_m, "ret_disp": ret_d,
"volatility": vol_p,
}
assigned: dict[int, str] = {}
remaining = list(range(n_states))
def _classify_state(stats: dict) -> str:
if (
stats["persistence"] >= _TREND_PERSIST
and stats["entropy"] <= _TREND_ENTROPY
):
return REGIME_TREND
if (
stats["volatility"] >= _SHOCK_VOL_THR
or stats["ret_disp"] >= _SHOCK_DISP_THR
):
return REGIME_SHOCK
return REGIME_MR
# ── Step 1: assign VOLATILITY_SHOCK ──────────────────────────────────────
shock_cands = [
i for i in remaining
if per_state[i]["vol_mean"] > _SHOCK_VOL_THR
and per_state[i]["ret_disp"] > _SHOCK_DISP_THR
]
if shock_cands:
shock_id = max(shock_cands,
key=lambda i: per_state[i]["vol_mean"] + per_state[i]["ret_disp"])
else:
shock_id = max(remaining,
key=lambda i: per_state[i]["vol_mean"] + per_state[i]["ret_disp"])
assigned[shock_id] = REGIME_SHOCK
remaining.remove(shock_id)
assigned: dict[int, str] = {i: _classify_state(per_state[i]) for i in range(n_states)}
# ── Step 2: assign TREND ──────────────────────────────────────────────────
trend_cands = [
i for i in remaining
if per_state[i]["persistence"] > _TREND_PERSIST
and per_state[i]["entropy"] < _TREND_ENTROPY
]
if trend_cands:
trend_id = max(trend_cands, key=lambda i: per_state[i]["persistence"])
else:
trend_id = max(remaining, key=lambda i: per_state[i]["persistence"])
assigned[trend_id] = REGIME_TREND
remaining.remove(trend_id)
# ── Step 3: remaining states → MEAN_REVERSION ────────────────────────────
for i in remaining:
assigned[i] = REGIME_MR
# Build raw→canonical remap
remap: dict[int, int] = {
# Mapping used for the currently predicted raw state sequence.
old_remap: dict[int, int] = {
raw: CANONICAL_REGIME_ID[label] for raw, label in assigned.items()
}
remapped = np.array([old_remap.get(int(s), 1) for s in raw_states], dtype=np.int32)
# Permute model internals so canonical ID 0 addresses the TREND state etc.
# For n_states > 3 with multiple MR states this is best-effort.
perm = list(range(n_states)) # identity fallback
for raw, can in remap.items():
if can < n_states:
perm[can] = raw
# Best-effort permutation: choose a representative raw state for each
# canonical bucket so model.transmat_[0/1/2] remains semantically meaningful.
groups: dict[int, list[int]] = {0: [], 1: [], 2: []}
for raw, can in old_remap.items():
groups[int(can)].append(int(raw))
reps: dict[int, int] = {}
for can_id in (0, 1, 2):
cand = groups.get(can_id, [])
if cand:
reps[can_id] = max(cand, key=lambda s: raw_counts.get(int(s), 0))
perm: list[int] = []
used: set[int] = set()
for can_id in (0, 1, 2):
r = reps.get(can_id)
if r is not None and r not in used:
perm.append(int(r))
used.add(int(r))
continue
for raw in range(n_states):
if raw not in used:
perm.append(int(raw))
used.add(int(raw))
break
if len(perm) < n_states:
for raw in range(n_states):
if raw not in used:
perm.append(int(raw))
used.add(int(raw))
remap: dict[int, int] = old_remap
try:
# Validate perm is a valid permutation before applying
if sorted(perm) == list(range(n_states)):
model.means_ = model.means_[perm]
model.covars_ = model.covars_[perm]
model.transmat_ = model.transmat_[np.ix_(perm, perm)]
model.means_ = model.means_[perm]
model.covars_ = model.covars_[perm]
model.transmat_ = model.transmat_[np.ix_(perm, perm)]
model.startprob_ = model.startprob_[perm]
# After permutation raw IDs shift — rebuild remap from scratch
remap = {i: CANONICAL_REGIME_ID[assigned[perm[i]]] for i in range(n_states)}
remap = {
int(new_idx): old_remap.get(int(old_idx), 1)
for new_idx, old_idx in enumerate(perm)
}
except (IndexError, ValueError):
pass # non-injective perm (4-state with 2 MR); keep original order
remap = old_remap
remapped = np.array([remap.get(int(s), 1) for s in raw_states], dtype=np.int32)
state_names: dict[int, str] = {
CANONICAL_REGIME_ID[lbl]: lbl
for lbl in (REGIME_TREND, REGIME_MR, REGIME_SHOCK)
@@ -210,9 +213,10 @@ def _save_regime_diagnostics(
can_id = remap.get(raw_id, raw_id)
label = STATE_NAMES.get(can_id, f"state_{raw_id}")
row = np.clip(model.transmat_[raw_id], eps, 1.0)
states_info[str(can_id)] = {
states_info[f"raw_state_{raw_id}"] = {
"label": label,
"raw_id": raw_id,
"canonical_id": int(can_id),
"persistence": round(float(model.transmat_[raw_id, raw_id]), 6),
"entropy_bits": round(float(-np.sum(row * np.log2(row))), 6),
"vol_mean_scaled": round(float(model.means_[raw_id, 1]), 6),
@@ -307,6 +311,11 @@ def fit_hmm(
random_state: int = 42,
tf: str = "H1",
):
if int(n_states) != 3:
raise ValueError(
f"fit_hmm[{tf}]: n_states must be 3 (canonical contract), got {n_states}."
)
X_raw = df[["kalman_return", "volatility", "rsi_slope"]].values
# Normalise all three features to zero mean / unit variance so that
# rsi_slope (typical range ±5) cannot dominate the regime classification
@@ -384,6 +393,11 @@ def predict_states(model: GaussianHMM, df: pd.DataFrame, tf: str = "H1") -> np.n
Falls back to legacy return-sorted ordering for pre-rebuild model files.
"""
if int(getattr(model, "n_components", 0)) != 3:
raise ValueError(
f"predict_states[{tf}]: expected model.n_components == 3, got {getattr(model, 'n_components', None)}."
)
X_raw = df[["kalman_return", "volatility", "rsi_slope"]].values
scaler = getattr(model, "_obs_scaler", None)
X = scaler.transform(X_raw) if scaler is not None else X_raw
+106
View File
@@ -848,6 +848,112 @@ def get_regime_predictions(X, states, trend_model, shock_model, fallback_prob=0.
return probs
def predict_by_regime(regime_label: str, row_features, models: dict) -> float | None:
"""Route a single-row probability request to the matching regime model.
Returns None when no model is available for the requested regime.
"""
if regime_label == "TREND" and models.get("TREND") is not None:
return float(models["TREND"].predict_proba(row_features)[0, 1])
if regime_label == "VOLATILITY_SHOCK" and models.get("VOLATILITY_SHOCK") is not None:
return float(models["VOLATILITY_SHOCK"].predict_proba(row_features)[0, 1])
return None
def regime_first_probability(
features_df,
hmm_state: int,
tf: str,
broker: str = "headway_cent",
trend_model=None,
shock_model=None,
ensemble_models: dict | None = None,
ensemble_thresholds: tuple[float, float] | None = None,
fallback_prob: float = 0.50,
) -> tuple[float, str]:
"""Return single-row probability using regime models first, then ensemble fallback.
Routing policy:
- TREND / VOLATILITY_SHOCK: use matching regime model when available.
- MEAN_REVERSION: return neutral fallback_prob.
- If a tradeable regime model is unavailable, fall back to ensemble.
"""
from src.engine_hmm import STATE_NAMES_3
regime_label = STATE_NAMES_3.get(int(hmm_state), "MEAN_REVERSION")
if trend_model is None and shock_model is None:
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
regime_models = {
"TREND": trend_model,
"VOLATILITY_SHOCK": shock_model,
}
prob = predict_by_regime(regime_label, features_df, regime_models)
if prob is not None:
return float(prob), "regime_model"
if regime_label == "MEAN_REVERSION":
return float(fallback_prob), "mr_neutral"
if ensemble_models is None or ensemble_thresholds is None:
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = ENSEMBLE_PKL_PATH
if not xgb_path.exists():
raise FileNotFoundError(
f"No ensemble model found for fallback at {get_ensemble_path(tf, broker)} or {ENSEMBLE_PKL_PATH}"
)
ensemble_models, ensemble_thresholds, _ = load_xgb_ensemble(xgb_path)
_, probs = get_predictions_ensemble(ensemble_models, ensemble_thresholds, features_df)
return float(probs[0]), "ensemble_fallback"
def regime_first_probabilities(
X,
states,
tf: str,
broker: str = "headway_cent",
trend_model=None,
shock_model=None,
ensemble_models: dict | None = None,
ensemble_thresholds: tuple[float, float] | None = None,
fallback_prob: float = 0.50,
) -> tuple[np.ndarray, str]:
"""Return batch probabilities with regime models preferred over ensemble.
If at least one regime model exists, probabilities are generated via
get_regime_predictions (MR and missing model paths stay neutral at fallback_prob).
If no regime models exist, falls back to ensemble probabilities.
"""
if trend_model is None and shock_model is None:
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
if trend_model is not None or shock_model is not None:
probs = get_regime_predictions(
X=X,
states=states,
trend_model=trend_model,
shock_model=shock_model,
fallback_prob=fallback_prob,
)
return probs, "regime_models"
if ensemble_models is None or ensemble_thresholds is None:
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = ENSEMBLE_PKL_PATH
if not xgb_path.exists():
raise FileNotFoundError(
f"No regime models or ensemble fallback found for {tf}/{broker}."
)
ensemble_models, ensemble_thresholds, _ = load_xgb_ensemble(xgb_path)
_, probs = get_predictions_ensemble(ensemble_models, ensemble_thresholds, X)
return probs, "ensemble_fallback"
def save_regime_models(regime_result, tf, broker='headway_cent'):
# Persist trend_model and shock_model to models/ directory.
for key in ('trend_model', 'shock_model'):
+141 -98
View File
@@ -37,13 +37,21 @@ from src.processor import (
load_feature_scaler,
CONTINUOUS_FEATURE_COLS,
)
from src.engine_hmm import load_model as load_hmm, predict_states, get_model_path as hmm_model_path, MODEL_PATH as HMM_GENERIC_PATH, STATE_NAMES_2, STATE_NAMES_3, STATE_NAMES_4
from src.engine_hmm import (
load_model as load_hmm,
predict_states,
get_model_path as hmm_model_path,
MODEL_PATH as HMM_GENERIC_PATH,
STATE_NAMES,
REGIME_SHOCK,
)
from src.engine_xgb import (
load_xgb_ensemble, get_predictions_ensemble, assign_vol_bucket, FEATURE_COLS,
load_xgb_ensemble, assign_vol_bucket, FEATURE_COLS,
get_ensemble_path, ENSEMBLE_PKL_PATH as XGB_GENERIC_PATH,
load_regime_models, regime_first_probability,
)
from src.risk_manager import AdaptiveRiskManager, BROKER_CONFIGS, CENT_MULTIPLIER, DailyEquityGate
from src.signal_engine import SignalEngine, should_apply_prob_gate
from src.signal_engine import SignalEngine
from src.trade_lifecycle import config_for_tf, floating_pnl_usd
logger = setup_logger(__name__)
@@ -1078,15 +1086,41 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
model_hmm = load_hmm(hmm_path)
except FileNotFoundError:
raise FileNotFoundError("HMM model not found. Run --mode train first.")
if int(getattr(model_hmm, "n_components", 0)) != 3:
raise RuntimeError(
f"Live runtime requires a 3-state HMM model; found n_components={getattr(model_hmm, 'n_components', None)}. "
"Re-train with canonical 3 states before live trading."
)
trend_model, shock_model = load_regime_models(tf=tf, broker=broker)
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = XGB_GENERIC_PATH
try:
models_xgb, thresholds_xgb, xgb_meta = load_xgb_ensemble(xgb_path)
feature_cols = xgb_meta.get("feature_cols", list(FEATURE_COLS))
except FileNotFoundError:
raise FileNotFoundError("XGB ensemble model not found. Run --mode train first.")
models_xgb = None
thresholds_xgb = None
xgb_meta = {}
if xgb_path.exists():
try:
models_xgb, thresholds_xgb, xgb_meta = load_xgb_ensemble(xgb_path)
except FileNotFoundError:
models_xgb, thresholds_xgb, xgb_meta = None, None, {}
feature_cols = xgb_meta.get("feature_cols")
if not feature_cols:
_regime_cols = None
for _m in (trend_model, shock_model):
_names = getattr(_m, "feature_names_in_", None) if _m is not None else None
if _names is not None and len(_names) > 0:
_regime_cols = [str(c) for c in _names]
break
feature_cols = _regime_cols or list(FEATURE_COLS)
if trend_model is None and shock_model is None and (models_xgb is None or thresholds_xgb is None):
raise FileNotFoundError(
"No regime models or XGB ensemble model found. Run --mode train first."
)
# Resolve Kalman params from Optuna study or TF defaults
try:
@@ -1109,11 +1143,19 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
cooldown_bars = 0 # bars to wait after a trade close before evaluating new signals
# Tracks the tickets and entry context of the most recently placed signal
# so the break-even SL logic can fire when TP1 closes position 1.
signal_tracker = {"tickets": [], "entry_price": 0.0,
"direction": None, "tp1_hit": False,
"tp1_level": None, "guard_hit": False,
"signal_type": None,
"atr_price": 0.0} # cached price-denom ATR for between-bar trail
signal_tracker = {
"tickets": [],
"entry_price": 0.0,
"direction": None,
"tp1_hit": False,
"tp1_level": None,
"guard_hit": False,
"signal_type": None,
"atr_price": 0.0,
"bars_in_trade": 0,
"max_fav_pnl_usd": 0.0,
"entry_regime": None,
} # cached ATR + lifecycle tracking for unified evaluate_bar exits
# Per-ticket ATR trail state: {ticket: {"activated": bool, "partial_done": bool, "current_sl": float}}
atr_state_tracker: dict = {}
# Deprecated peak-pnl tracker kept for cleanup compat (atr_state_tracker supersedes it)
@@ -1383,8 +1425,17 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
_upd.update_all_timeframes()
except Exception as _upd_exc:
logger.debug("Weekly data updater: %s", _upd_exc)
_, _probs = get_predictions_ensemble(models_xgb, thresholds_xgb, features_df)
prob = float(_probs[0])
prob, prob_source = regime_first_probability(
features_df=features_df,
hmm_state=hmm_state,
tf=tf,
broker=broker,
trend_model=trend_model,
shock_model=shock_model,
ensemble_models=models_xgb,
ensemble_thresholds=thresholds_xgb,
)
logger.info("Probability source: %s | prob=%.3f", prob_source, prob)
gmm_cluster = int(features_df["gmm_vol_cluster"].iloc[0]) if "gmm_vol_cluster" in features_df.columns else -1
# ── Spread efficiency filter ──────────────────────────────────────
@@ -1402,8 +1453,7 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
# ── 5. Log bar info on every new bar (always visible) ─────────
bar_str = datetime.fromtimestamp(bar_time, timezone.utc).strftime("%Y-%m-%d %H:%M")
_snames = {2: STATE_NAMES_2, 3: STATE_NAMES_3, 4: STATE_NAMES_4}
state_lbl = _snames.get(model_hmm.n_components, STATE_NAMES_4).get(hmm_state, str(hmm_state))
state_lbl = STATE_NAMES.get(int(hmm_state), str(hmm_state))
logger.info(
"Bar %s | state=%s | prob=%.3f | Efficiency=%.1fx",
bar_str, hmm_state, prob, _er,
@@ -1415,13 +1465,20 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
f"Efficiency: <b>{_er:.1f}x</b>"
)
# ── 6. Position management (always runs — P&L, break-even, chop-exit)
from src.processor import compute_synth_vix, compute_dynamic_atr_bands
_svix_raw = compute_synth_vix(live_df)
_svix_val = float(_svix_raw.iloc[-1]) if not np.isnan(_svix_raw.iloc[-1]) else 0.0
_atr_bands = compute_dynamic_atr_bands(live_df)
_atr_band_val = float(_atr_bands.iloc[-1]) if not np.isnan(_atr_bands.iloc[-1]) else 0.5
# ── 6. Position management (always runs — P&L, break-even, policy-exit)
if signal_tracker["tickets"]:
open_set = {
p.ticket
for p in (mt5.positions_get(symbol=DEFAULT_SYMBOL) or [])
_all_pos = [
p for p in (mt5.positions_get(symbol=DEFAULT_SYMBOL) or [])
if p.magic == magic
}
]
_pos_by_ticket = {int(p.ticket): p for p in _all_pos}
open_set = set(_pos_by_ticket.keys())
active = [t for t in signal_tracker["tickets"] if t in open_set]
closed = [t for t in signal_tracker["tickets"] if t not in open_set]
# Log P&L for any positions that closed since last bar
@@ -1435,21 +1492,49 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
signal_tracker["tp1_hit"] = True
for ticket in active:
_move_sl_to_breakeven(ticket, signal_tracker["entry_price"], mt5)
# Regime shifted to any Chop state while a TREND runner is active → close immediately.
# Use >= CHOP_STATE to cover both Chop_Low (2) and Chop_High (3) in 4-state models.
if signal_tracker.get("signal_type") == "trend" and hmm_state >= CHOP_STATE and active:
for ticket in active:
_close_position(ticket, mt5, magic=magic)
logger.info("Trend runner(s) closed: regime shifted to Chop.")
active = []
elif signal_tracker.get("signal_type") == "mean_reversion" and hmm_state < CHOP_STATE and active:
for ticket in active:
_close_position(ticket, mt5, comment="GRX_close_mr_breakout", magic=magic)
logger.info("MR position(s) closed: Chop ended, regime broke out to state %d.", hmm_state)
active = []
if active:
signal_tracker["bars_in_trade"] = int(signal_tracker.get("bars_in_trade", 0)) + 1
_cur_pnl_raw = float(
sum(_pos_by_ticket[t].profit for t in active if t in _pos_by_ticket)
)
_cur_pnl_usd = float(floating_pnl_usd(_cur_pnl_raw, broker))
_max_fav = float(signal_tracker.get("max_fav_pnl_usd", 0.0))
signal_tracker["max_fav_pnl_usd"] = max(_max_fav, _cur_pnl_usd)
_pos_ctx = {
"direction": 1 if signal_tracker.get("direction") == "BUY" else -1,
"cur_pnl": _cur_pnl_usd,
"max_fav_pnl": float(signal_tracker.get("max_fav_pnl_usd", 0.0)),
"bars_in_trade": int(signal_tracker.get("bars_in_trade", 0)),
}
_row_ctx_exit = {
"hmm_state": int(hmm_state),
"prob": float(prob),
"synth_vix_zscore": _svix_val,
"atr_band_position": _atr_band_val,
}
_exit_decision = signal_engine.evaluate_bar(
_row_ctx_exit,
_pos_ctx,
{"tf": tf, "hmm_transmat": model_hmm.transmat_},
)
if _exit_decision.exit:
for ticket in active:
_close_position(
ticket,
mt5,
comment=f"GRX_policy_exit_{_exit_decision.reason}",
magic=magic,
)
logger.info("Policy exit triggered: %s", _exit_decision.reason)
active = []
signal_tracker["tickets"] = active
if not active:
signal_engine.on_trade_closed()
signal_tracker["bars_in_trade"] = 0
signal_tracker["max_fav_pnl_usd"] = 0.0
cooldown_bars = 3
logger.info("Post-trade cooldown activated: 3 bars.")
if equity_gate.locked:
@@ -1496,29 +1581,8 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
# ── 9. Signal routing — regime-confirmation engine ────────────
# PROBABILITY GATE: H1 only — M15/M5 bypass via should_apply_prob_gate().
# XGB probabilities cluster near 50-55% on short TFs; a strict gate
# there produces a dead zone that blocks valid signals.
_thresh_long = TF_PROB_THRESHOLD.get(tf.upper(), 0.55)
_thresh_short = TF_SHORT_THRESHOLD.get(tf.upper(), 0.45)
if should_apply_prob_gate(tf) and _thresh_short < prob < _thresh_long:
logger.info(
"Signal rejected [%s]: prob=%.3f in noise zone (%.2f - %.2f).",
tf, prob, _thresh_short, _thresh_long,
)
time.sleep(POLL_INTERVAL_SEC)
continue
_bb_pos = _calculate_bb_position(np.array(close_prices_cache))
# ── Signal engine inputs: Z-score and ATR band position ───────────
from src.processor import compute_synth_vix, compute_dynamic_atr_bands
_svix_raw = compute_synth_vix(live_df)
_svix_val = float(_svix_raw.iloc[-1]) if not np.isnan(_svix_raw.iloc[-1]) else 0.0
_atr_bands = compute_dynamic_atr_bands(live_df)
_atr_band_val = float(_atr_bands.iloc[-1]) if not np.isnan(_atr_bands.iloc[-1]) else 0.5
# ── Single policy call via evaluate_bar (Phase 2) ─────────────────
# evaluate_bar calls update_regime internally and enforces M15/M5
# specialization gates (_m15_entry_ok, dynamic_prob_threshold).
@@ -1545,49 +1609,23 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
time.sleep(POLL_INTERVAL_SEC)
continue
# Map SignalDecision back to signal string for downstream execution code.
_is_mr = "mr" in _entry_decision.reason.lower()
_dir_int = _entry_decision.direction
if _dir_int == 1:
_sig_str = "MR_BUY" if _is_mr else "BUY"
else:
_sig_str = "MR_SELL" if _is_mr else "SELL"
_size_mult = _entry_decision.position_size_multiplier
if _sig_str == "BUY":
if _dir_int == 1:
direction = "BUY"
order_type = mt5.ORDER_TYPE_BUY
signal_type = "trend"
logger.info("[SIGNAL] BUY (trend) | prob=%.3f | bars=%d | P(stay)=%.2f",
signal_type = "shock" if _entry_decision.regime == REGIME_SHOCK else "trend"
logger.info("[SIGNAL] BUY (%s) | prob=%.3f | bars=%d | P(stay)=%.2f",
signal_type,
prob, signal_engine.bars_in_regime, _get_transition_prob(model_hmm, hmm_state))
elif _sig_str == "SELL":
elif _dir_int == -1:
direction = "SELL"
order_type = mt5.ORDER_TYPE_SELL
signal_type = "trend"
logger.info("[SIGNAL] SELL (trend) | prob=%.3f | bars=%d | P(stay)=%.2f",
signal_type = "shock" if _entry_decision.regime == REGIME_SHOCK else "trend"
logger.info("[SIGNAL] SELL (%s) | prob=%.3f | bars=%d | P(stay)=%.2f",
signal_type,
prob, signal_engine.bars_in_regime, _get_transition_prob(model_hmm, hmm_state))
elif _sig_str == "MR_BUY":
direction = "BUY"
order_type = mt5.ORDER_TYPE_BUY
signal_type = "mean_reversion"
logger.info("[SIGNAL] MR_BUY | prob=%.3f | BB=%.2f | bars=%d",
prob, _bb_pos, signal_engine.bars_in_regime)
if gmm_cluster == 2:
logger.warning(
"[MR WARNING] High-vol environment (GMM cluster=2) — "
"MR has lower edge in breakout conditions."
)
elif _sig_str == "MR_SELL":
direction = "SELL"
order_type = mt5.ORDER_TYPE_SELL
signal_type = "mean_reversion"
logger.info("[SIGNAL] MR_SELL | prob=%.3f | BB=%.2f | bars=%d",
prob, _bb_pos, signal_engine.bars_in_regime)
if gmm_cluster == 2:
logger.warning(
"[MR WARNING] High-vol environment (GMM cluster=2) — "
"MR has lower edge in breakout conditions."
)
else:
time.sleep(POLL_INTERVAL_SEC)
continue
@@ -1597,9 +1635,6 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
limits = arm.get_trade_limits(hmm_state)
pos_per_trade = min(limits["pos_per_trade"], len(tp_mults))
sl_distance = max(atr_price * TF_ATR_MULTIPLIER.get(tf.upper(), 2.0), 0.01)
if signal_type == "mean_reversion":
sl_distance *= 0.70 # MR trades use tighter SL — mean-reversion has defined exit
logger.info("[MR RISK ADJ] SL tightened to 70%% of base: %.4f", sl_distance)
# $15 accounts use hardcoded lot splits — bypass AdaptiveRiskManager.
# Cent: 0.02 (pos1) + 0.03 (pos2) = 0.05 micro-lots total.
@@ -1660,10 +1695,18 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
continue
# ── 13. Send order(s) with state-aware staged TPs ─────────────
signal_tracker = {"tickets": [], "entry_price": entry_price,
"direction": direction, "tp1_hit": False,
"tp1_level": tp_levels[0], "guard_hit": False,
"signal_type": signal_type}
signal_tracker = {
"tickets": [],
"entry_price": entry_price,
"direction": direction,
"tp1_hit": False,
"tp1_level": tp_levels[0],
"guard_hit": False,
"signal_type": signal_type,
"bars_in_trade": 0,
"max_fav_pnl_usd": 0.0,
"entry_regime": _entry_decision.regime,
}
peak_pnl_tracker = {}
logger.info(
"SIGNAL %s | state=%d | prob=%.3f | size=%.2f | lots=%s | sl=%.2f | tp=%s | dev=%d",
@@ -1674,7 +1717,7 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
for p in range(pos_per_trade):
tp_price = tp_levels[p]
current_lot = forced_lots[p] if p < len(forced_lots) else forced_lots[-1]
_trade_tag = "MR" if signal_type == "mean_reversion" else "TREND"
_trade_tag = "SHOCK" if signal_type == "shock" else "TREND"
comment = f"GRX_{tf}_{_trade_tag}_{direction}_s{hmm_state}_tp{p+1}"
result = send_market_order(
symbol=DEFAULT_SYMBOL,
@@ -1699,7 +1742,7 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
if signal_tracker["tickets"]:
signal_engine.on_trade_entered(hmm_state)
_emoji = "🟢" if direction == "BUY" else "🔴"
_tag = "📐 MEAN REVERSION" if signal_type == "mean_reversion" else "📈 TREND"
_tag = "⚡ VOLATILITY SHOCK" if signal_type == "shock" else "📈 TREND"
_tp_str = " / ".join(f"{t:.2f}" for t in tp_levels)
_tix = " ".join(f"#{t}" for t in signal_tracker["tickets"])
send_telegram_msg(
+419 -160
View File
@@ -28,13 +28,20 @@ import pandas as pd
from sklearn.metrics import log_loss, roc_auc_score
from sklearn.model_selection import TimeSeriesSplit
from src.processor import kalman_smooth
from src.engine_hmm import fit_hmm, predict_states
from src.engine_hmm import fit_hmm, predict_states, STATE_NAMES
from src.engine_xgb import (
prepare_features, train_xgb_ensemble, get_predictions_ensemble,
compute_regime_stats, get_feature_cols,
train_regime_models, get_regime_predictions,
)
from src.backtester import vectorized_backtest
from src.cpcv_reporting import build_cpcv_report, write_cpcv_report, write_legacy_cpcv_report
from src.optuna_param_guard import audit_search_space, search_space_as_text
from src.regime_diagnostics import (
summarize_regimes,
get_occupancy_percentage,
write_regime_diagnostics,
)
from src.risk_manager import SMALL_ACCOUNT_THRESHOLD
from src.logger import setup_logger, append_trial_score
@@ -163,6 +170,13 @@ CPCV_TRIALS = {"H1": 80, "M15": 120, "M5": 200}
CPCV_PURGE_BARS = {"H1": 24, "M15": 96, "M5": 288}
MIN_TRADES_PER_PATH = {"H1": 15, "M15": 60, "M5": 100}
# Validation and deployment thresholds are fixed governance constants.
VALIDATION_CONFIG = {
"H1": {"min_median_sharpe": 0.25, "min_median_pf": 1.10, "max_trades_per_100": 8},
"M15": {"min_median_sharpe": 0.20, "min_median_pf": 1.05, "max_trades_per_100": 20},
"M5": {"min_median_sharpe": 0.10, "min_median_pf": 1.00, "max_trades_per_100": 35},
}
# ── Utility helpers ────────────────────────────────────────────────────────────
@@ -951,11 +965,8 @@ def make_objective_stage1(
obs_cov = trial.suggest_float("obs_cov", 0.5, 5.0, log=True)
trans_cov = trial.suggest_float("trans_cov", 0.001, 0.03, log=True)
# M5/M15 fixed at 3 states — same reasoning as full CPCV objective
if tf_up in ("M5", "M15"):
n_states = 3
else: # H1
n_states = trial.suggest_int("n_states", 3, 4)
# Canonical contract: all TFs use exactly 3 states.
n_states = 3
# ── XGBoost params (same ranges as full CPCV objective) ───────────────
if tf_up == "M5":
@@ -1113,6 +1124,7 @@ def format_trial_summary(row: dict) -> str:
"tf", "trial", "objective_score", "cpcv_mean_score", "cpcv_std_score",
"valid_paths", "total_paths", "sharpe_median", "pf_median", "calmar_median",
"expectancy_median", "floating_dd_median", "trades_median", "mr_leak_count",
"regime_occupancy", "trade_distribution",
"activation_events", "partial_close_events", "trail_updates",
"regime_shift_forced_closes", "avg_activation_pnl_usd",
"pruned", "elapsed_sec",
@@ -1122,56 +1134,38 @@ def format_trial_summary(row: dict) -> str:
# ── H1 profitability safeguards ─────────────────────────────────────────────
def _apply_h1_hard_floors(metrics: dict, params: dict) -> "tuple[float, list[str]]":
"""Apply hard-floor penalty for H1 CPCV quality metrics.
def _apply_validation_floors(metrics: dict, tf: str) -> "tuple[float, list[str]]":
"""Apply fixed validation quality floors by timeframe."""
tf_up = tf.upper()
cfg = VALIDATION_CONFIG.get(tf_up, VALIDATION_CONFIG["H1"])
Returns (penalty, reasons) where reasons is a list of grep-friendly tags.
Floors are trial params so Optuna learns which param combos can meet them.
"""
penalty: float = 0.0
reasons: list = []
min_sharpe = float(params.get("h1_min_median_sharpe", 0.10))
min_exp = float(params.get("h1_min_median_expectancy", 0.0))
min_pf = float(params.get("h1_min_median_pf", 1.02))
min_valid = int( params.get("h1_min_valid_paths", 0))
min_sharpe = float(cfg.get("min_median_sharpe", 0.0))
min_pf = float(cfg.get("min_median_pf", 1.0))
med_sharpe = float(metrics.get("median_sharpe", 0.0))
med_exp = float(metrics.get("median_expectancy", 0.0))
med_pf = float(metrics.get("median_pf", 1.0))
valid_paths = int( metrics.get("n_valid_paths", 0))
if med_sharpe < min_sharpe:
penalty += 10.0 + (min_sharpe - med_sharpe) * 10.0
reasons.append(f"floor_sharpe:{med_sharpe:.3f}<{min_sharpe:.3f}")
if med_exp <= min_exp:
penalty += 8.0 + (min_exp - med_exp) * 25.0
reasons.append(f"floor_expectancy:{med_exp:.5f}<={min_exp:.5f}")
if med_pf < min_pf:
penalty += 6.0 + (min_pf - med_pf) * 12.0
reasons.append(f"floor_pf:{med_pf:.3f}<{min_pf:.3f}")
if min_valid > 0 and valid_paths < min_valid:
penalty += 8.0 + (min_valid - valid_paths) * 1.5
reasons.append(f"floor_valid_paths:{valid_paths}<{min_valid}")
return penalty, reasons
def _turnover_penalty(metrics: dict, params: dict, tf: str) -> "tuple[float, list[str]]":
"""Penalise excessive churn; H1-only (M15/M5 use spread-penalty in execute_cpcv).
Returns (penalty, reasons).
"""
if tf.upper() != "H1":
return 0.0, []
def _turnover_penalty(metrics: dict, tf: str) -> "tuple[float, list[str]]":
"""Penalise excessive churn using fixed max-trade governance by timeframe."""
tf_up = tf.upper()
cfg = VALIDATION_CONFIG.get(tf_up, VALIDATION_CONFIG["H1"])
trades_per_100 = float(metrics.get("trades_per_100_bars", 0.0))
reversal_ratio = float(metrics.get("reversal_ratio", 0.0)) # future metric
max_t100 = float(params.get("h1_max_trades_per_100", 4.0))
max_rev = float(params.get("h1_max_reversal_ratio", 0.35))
max_t100 = float(cfg.get("max_trades_per_100", 20.0))
penalty: float = 0.0
reasons: list = []
@@ -1180,22 +1174,13 @@ def _turnover_penalty(metrics: dict, params: dict, tf: str) -> "tuple[float, lis
penalty += (trades_per_100 - max_t100) * 1.5
reasons.append(f"turnover:{trades_per_100:.2f}>{max_t100:.2f}")
if reversal_ratio > max_rev:
penalty += (reversal_ratio - max_rev) * 2.0
reasons.append(f"reversals:{reversal_ratio:.2f}>{max_rev:.2f}")
return penalty, reasons
# ── TF-specific objective weights ────────────────────────────────────────────
def _tf_pipeline_score(cpcv_result: dict, tf: str) -> float:
"""Apply TF-specific objective weights to CPCV aggregate metrics.
H1 : 0.50*expectancy + 0.30*calmar + 0.20*stability
M15 : 0.40*profit_factor + 0.30*sharpe + 0.30*calmar
M5 : 0.40*stability + 0.30*profit_factor + 0.30*drawdown_score
"""
def _tf_objective_breakdown(cpcv_result: dict, tf: str) -> dict:
"""Return weighted objective component breakdown for timeframe-specific scoring."""
tf_up = tf.upper()
median_sharpe = float(cpcv_result.get("median_sharpe", 0.0))
median_pf = float(cpcv_result.get("median_pf", 1.0))
@@ -1204,16 +1189,40 @@ def _tf_pipeline_score(cpcv_result: dict, tf: str) -> float:
stability = float(cpcv_result.get("median_win_rate", 0.0))
median_dd = float(cpcv_result.get("median_drawdown", 0.0))
drawdown_score = max(0.0, 1.0 - median_dd / max(CPCV_MAX_FLOAT_DD, 1e-6))
drawdown_control = max(0.0, 1.0 - median_dd / max(CPCV_MAX_FLOAT_DD, 1e-6))
if tf_up == "H1":
score = 0.50 * median_expectancy + 0.30 * median_calmar + 0.20 * stability
weights = {"expectancy": 0.35, "calmar": 0.35, "profit_factor": 0.30}
components = {
"expectancy": median_expectancy,
"calmar": median_calmar,
"profit_factor": median_pf,
}
elif tf_up == "M15":
score = 0.40 * median_pf + 0.30 * median_sharpe + 0.30 * median_calmar
weights = {"profit_factor": 0.40, "sharpe": 0.30, "calmar": 0.30}
components = {
"profit_factor": median_pf,
"sharpe": median_sharpe,
"calmar": median_calmar,
}
else: # M5
score = 0.40 * stability + 0.30 * median_pf + 0.30 * drawdown_score
weights = {"stability": 0.40, "profit_factor": 0.30, "drawdown_control": 0.30}
components = {
"stability": stability,
"profit_factor": median_pf,
"drawdown_control": drawdown_control,
}
return float(score)
base_score = float(sum(weights[k] * components[k] for k in weights))
return {
"weights": weights,
"components": components,
"base_score": base_score,
}
def _tf_pipeline_score(cpcv_result: dict, tf: str) -> float:
return float(_tf_objective_breakdown(cpcv_result, tf).get("base_score", 0.0))
# ── Unified single-stage pipeline optimizer ───────────────────────────────────
@@ -1252,16 +1261,34 @@ class PipelineOptimizer:
)
self._jsonl_path.parent.mkdir(parents=True, exist_ok=True)
def active_param_names(self) -> list[str]:
"""Return the exact Optuna-tuned parameter names for this timeframe."""
base = [
"obs_cov",
"trans_cov",
"persistence_threshold",
"max_depth",
"learning_rate",
"n_estimators",
"subsample",
"colsample_bytree",
"min_child_weight",
"gamma",
"reg_alpha",
"reg_lambda",
"scale_pos_weight",
]
return sorted(base)
# ── Parameter space ───────────────────────────────────────────────────────
def suggest_params(self, trial: "optuna.Trial") -> dict:
"""Suggest hyperparameters for one trial.
New unified parameter space:
- HMM: n_states, obs_cov, trans_cov, persistence_threshold,
transition_penalty, median_filter_window
- GMM: gmm_n_components, gmm_covariance_type (future wiring)
- XGB: per-TF ranges per spec
Unified parameter space:
- HMM: n_states, obs_cov, trans_cov, persistence_threshold
- XGB: per-TF ranges
Validation floors, CPCV gates, and reporting thresholds are fixed.
"""
tf_up = self.tf
@@ -1315,16 +1342,10 @@ class PipelineOptimizer:
reg_lambda = trial.suggest_float("reg_lambda", 0.01, 2.0, log=True)
scale_pos_weight = trial.suggest_float("scale_pos_weight", 0.5, 2.0, log=True)
# H1-only entry gate and profitability floor params
# These are sampled so Optuna learns which XGB/HMM combos can meet stricter bars.
_h1_gates: dict = {}
if tf_up == "H1":
_h1_gates = {
"h1_entry_prob": trial.suggest_float("h1_entry_prob", 0.57, 0.66),
"h1_min_median_sharpe": trial.suggest_float("h1_min_median_sharpe", 0.05, 0.25),
"h1_min_median_pf": trial.suggest_float("h1_min_median_pf", 1.00, 1.10),
"h1_max_trades_per_100": trial.suggest_float("h1_max_trades_per_100", 2.5, 6.0),
}
_signal_cfg = {
"entry_probability_threshold": {"H1": 0.60, "M15": 0.55}.get(tf_up, None),
"confirmation_bars": {"H1": 4, "M15": 3, "M5": 2}.get(tf_up, 2),
}
return {
"kalman": {
@@ -1347,7 +1368,7 @@ class PipelineOptimizer:
"reg_lambda": reg_lambda,
"scale_pos_weight": scale_pos_weight,
},
"h1_gates": _h1_gates,
"signal": _signal_cfg,
}
# ── Single trial execution ────────────────────────────────────────────────
@@ -1367,10 +1388,97 @@ class PipelineOptimizer:
xgb_kwargs = params["xgb"]
hmm_params = params["hmm"]
h1_gates = params.get("h1_gates", {})
signal_cfg = params.get("signal", {})
n_states = int(hmm_params["n_states"])
tf_embargo = CPCV_PURGE_BARS.get(self.tf, 24)
# Phase 1: lightweight occupancy diagnostics + early gate before CPCV loops.
_gate_model, gate_states, _ = fit_hmm(
df_trial,
n_states=_enforce_three_states({"n_states": n_states}, "run_single_trial_gate"),
tf=self.tf,
)
_gate_returns = df_trial["log_return"].values[: len(gate_states)]
_diag = summarize_regimes(gate_states, _gate_returns, STATE_NAMES)
write_regime_diagnostics(
tf=self.tf,
states=gate_states,
returns=_gate_returns,
state_labels=STATE_NAMES,
output_dir="reports",
)
trend_pct = get_occupancy_percentage(_diag, "TREND")
shock_pct = get_occupancy_percentage(_diag, "VOLATILITY_SHOCK")
if trend_pct < 0.15 or shock_pct < 0.05:
fail_reason = []
if trend_pct < 0.15:
fail_reason.append(f"trend_pct<{0.15:.2f}")
if shock_pct < 0.05:
fail_reason.append(f"shock_pct<{0.05:.2f}")
fail_tag = ",".join(fail_reason)
summary = {
"tf": self.tf,
"trial": trial.number,
"objective_score": -90.0,
"cpcv_mean_score": -90.0,
"cpcv_std_score": 0.0,
"valid_paths": 0,
"total_paths": _N_PATHS,
"sharpe_median": 0.0,
"pf_median": 1.0,
"calmar_median": 0.0,
"expectancy_median": 0.0,
"floating_dd_median": 0.0,
"trades_median": 0,
"mr_leak_count": 0,
"activation_events": 0,
"partial_close_events": 0,
"trail_updates": 0,
"regime_shift_forced_closes": 0,
"avg_activation_pnl_usd": 0.0,
"p_floor": 0.0,
"p_turn": 0.0,
"floor_reasons": f"occupancy_gate:{fail_tag}",
"pruned": True,
"elapsed_sec": round(time.time() - t_start, 1),
}
return {
"objective_score": -90.0,
"cpcv": {
"cpcv_score": -90.0,
"n_valid_paths": 0,
"std_sharpe": 0.0,
"median_sharpe": 0.0,
"median_trades": 0,
"median_drawdown": 0.0,
"median_pf": 1.0,
"median_expectancy": 0.0,
"median_calmar": 0.0,
"total_mr_leaks": 0,
"regime_occupancy": {
"TREND": float(_diag.get("TREND", {}).get("percentage", 0.0)),
"MEAN_REVERSION": float(_diag.get("MEAN_REVERSION", {}).get("percentage", 0.0)),
"VOLATILITY_SHOCK": float(_diag.get("VOLATILITY_SHOCK", {}).get("percentage", 0.0)),
},
"trade_distribution": {
"TREND": 0,
"MEAN_REVERSION": 0,
"VOLATILITY_SHOCK": 0,
},
"path_scores": [],
"path_sharpes": [],
"path_trades": [],
"path_winrates": [],
"path_drawdowns": [],
"path_returns": [],
"path_pfs": [],
"path_expectancies": [],
},
"summary": summary,
"objective_breakdown": {"weights": {}, "components": {}, "base_score": 0.0},
"penalties": {"occupancy_gate": 90.0},
}
cpcv = execute_cpcv(
df_full = df_trial,
n_states = n_states,
@@ -1379,7 +1487,7 @@ class PipelineOptimizer:
broker = self.broker,
xgb_kwargs = xgb_kwargs,
hmm_params = hmm_params,
h1_gates = h1_gates,
signal_cfg = signal_cfg,
n_splits = CPCV_N_BLOCKS,
n_test_splits= CPCV_K_TEST,
embargo_bars = tf_embargo,
@@ -1387,7 +1495,8 @@ class PipelineOptimizer:
)
# TF-specific objective base score
base_score = _tf_pipeline_score(cpcv, self.tf)
objective_breakdown = _tf_objective_breakdown(cpcv, self.tf)
base_score = float(objective_breakdown.get("base_score", 0.0))
objective_score = base_score
# Penalties
@@ -1397,41 +1506,54 @@ class PipelineOptimizer:
mr_leaks = int( cpcv.get("total_mr_leaks", 0))
median_trades = int( cpcv.get("median_trades", 0))
variance_penalty = 0.10 * std_sharpe
instability_penalty = 0.50 * max(0, _N_PATHS - n_valid)
dd_penalty = 20.0 if median_dd > CPCV_MAX_FLOAT_DD else 0.0
mr_penalty = float(mr_leaks) * 5.0
variance_penalty = 0.10 * std_sharpe
instability_penalty = 0.50 * max(0, _N_PATHS - n_valid)
dd_penalty = 20.0 if median_dd > CPCV_MAX_FLOAT_DD else 0.0
mr_penalty = float(mr_leaks) * 5.0
# Excessive turnover penalty (especially harmful at M5 due to spread)
spread_penalty = 0.0
if self.tf == "M5" and median_trades > 500:
spread_penalty = 1.0 * ((median_trades - 500) / 500.0)
_test_bars = max(len(self.df) * CPCV_K_TEST // CPCV_N_BLOCKS, 1)
_cpcv_ext = dict(cpcv)
_cpcv_ext["trades_per_100_bars"] = (median_trades / _test_bars) * 100.0
objective_score -= (
variance_penalty + instability_penalty
+ dd_penalty + mr_penalty + spread_penalty
)
p_floor, floor_reasons = _apply_validation_floors(cpcv, self.tf)
p_turn, turn_reasons = _turnover_penalty(_cpcv_ext, self.tf)
# H1 profitability floors and turnover penalty
p_floor, floor_reasons = 0.0, []
p_turn, turn_reasons = 0.0, []
if self.tf == "H1":
# trades_per_100_bars: median_trades / (estimated OOS bars per path)
_test_bars = max(len(self.df) * CPCV_K_TEST // CPCV_N_BLOCKS, 1)
_cpcv_ext = dict(cpcv)
_cpcv_ext["trades_per_100_bars"] = (median_trades / _test_bars) * 100.0
p_floor, floor_reasons = _apply_h1_hard_floors(cpcv, h1_gates)
p_turn, turn_reasons = _turnover_penalty(_cpcv_ext, h1_gates, self.tf)
objective_score -= (p_floor + p_turn)
occ = cpcv.get("regime_occupancy", {})
occ_trend = float(occ.get("TREND", 0.0)) / 100.0
occ_shock = float(occ.get("VOLATILITY_SHOCK", 0.0)) / 100.0
occ_penalty = 0.0
occ_reasons: list[str] = []
if occ_trend < 0.15:
occ_penalty += (0.15 - occ_trend) * 20.0
occ_reasons.append("regime_occ_trend_low")
if occ_shock < 0.05:
occ_penalty += (0.05 - occ_shock) * 25.0
occ_reasons.append("regime_occ_shock_low")
all_reasons = floor_reasons + turn_reasons
m15_trade_penalty = 0.0
if self.tf == "M15" and median_trades > 400:
m15_trade_penalty = float(median_trades - 400) * 0.03
penalties = {
"variance": variance_penalty,
"instability": instability_penalty,
"drawdown": dd_penalty,
"mr_leak": mr_penalty,
"validation_floor": p_floor,
"overtrading": p_turn,
"regime_occupancy_instability": occ_penalty,
"m15_trade_excess": m15_trade_penalty,
}
objective_score -= float(sum(penalties.values()))
all_reasons = floor_reasons + turn_reasons + occ_reasons
logger.info(
"trial=%d tf=%s base=%.4f p_var=%.4f p_inst=%.4f p_dd=%.4f "
"p_mr=%.4f p_floor=%.4f p_turn=%.4f final=%.4f reasons=%s",
"p_mr=%.4f p_floor=%.4f p_turn=%.4f p_occ=%.4f p_m15=%.4f final=%.4f reasons=%s",
trial.number, self.tf,
base_score,
variance_penalty, instability_penalty, dd_penalty, mr_penalty,
p_floor, p_turn,
p_floor, p_turn, occ_penalty, m15_trade_penalty,
objective_score,
"|".join(all_reasons) or "none",
)
@@ -1458,6 +1580,8 @@ class PipelineOptimizer:
"trail_updates": int(cpcv.get("total_trail_updates", 0)),
"regime_shift_forced_closes": int(cpcv.get("total_regime_forced_closes", 0)),
"avg_activation_pnl_usd": round(float(cpcv.get("avg_activation_pnl_usd", 0.0)), 4),
"regime_occupancy": dict(cpcv.get("regime_occupancy", {})),
"trade_distribution": dict(cpcv.get("trade_distribution", {})),
"p_floor": round(p_floor, 4),
"p_turn": round(p_turn, 4),
"floor_reasons": "|".join(floor_reasons) or "none",
@@ -1465,7 +1589,13 @@ class PipelineOptimizer:
"elapsed_sec": elapsed,
}
return {"objective_score": objective_score, "cpcv": cpcv, "summary": summary}
return {
"objective_score": objective_score,
"cpcv": cpcv,
"summary": summary,
"objective_breakdown": objective_breakdown,
"penalties": penalties,
}
# ── Optuna objective ──────────────────────────────────────────────────────
@@ -1477,6 +1607,8 @@ class PipelineOptimizer:
score = result["objective_score"]
summary = result["summary"]
cpcv = result["cpcv"]
objective_breakdown = result.get("objective_breakdown", {})
penalties = result.get("penalties", {})
# Store attrs for Optuna history / callbacks
trial.set_user_attr("n_valid_paths", summary["valid_paths"])
@@ -1485,6 +1617,30 @@ class PipelineOptimizer:
trial.set_user_attr("median_sharpe", summary["sharpe_median"])
trial.set_user_attr("median_drawdown", summary["floating_dd_median"])
trial.set_user_attr("mr_leak_count", summary["mr_leak_count"])
trial.set_user_attr("median_win_rate", float(cpcv.get("median_win_rate", 0.0)))
trial.set_user_attr("median_return", float(cpcv.get("median_return", 0.0)))
trial.set_user_attr("median_pf", float(cpcv.get("median_pf", 1.0)))
trial.set_user_attr("median_expectancy", float(cpcv.get("median_expectancy", 0.0)))
trial.set_user_attr("median_calmar", float(cpcv.get("median_calmar", 0.0)))
trial.set_user_attr("path_scores", list(cpcv.get("path_scores", [])))
trial.set_user_attr("path_sharpes", list(cpcv.get("path_sharpes", [])))
trial.set_user_attr("path_trades", list(cpcv.get("path_trades", [])))
trial.set_user_attr("path_winrates", list(cpcv.get("path_winrates", [])))
trial.set_user_attr("path_drawdowns", list(cpcv.get("path_drawdowns", [])))
trial.set_user_attr("path_returns", list(cpcv.get("path_returns", [])))
trial.set_user_attr("path_pfs", list(cpcv.get("path_pfs", [])))
trial.set_user_attr("path_expectancies", list(cpcv.get("path_expectancies", [])))
trial.set_user_attr("total_activation_events", int(cpcv.get("total_activation_events", 0)))
trial.set_user_attr("total_partial_close_events", int(cpcv.get("total_partial_close_events", 0)))
trial.set_user_attr("total_regime_forced_closes", int(cpcv.get("total_regime_forced_closes", 0)))
trial.set_user_attr("total_trail_updates", int(cpcv.get("total_trail_updates", 0)))
trial.set_user_attr("avg_activation_pnl_usd", float(cpcv.get("avg_activation_pnl_usd", 0.0)))
trial.set_user_attr("regime_occupancy", cpcv.get("regime_occupancy", {}))
trial.set_user_attr("trade_distribution", cpcv.get("trade_distribution", {}))
trial.set_user_attr("objective_breakdown", objective_breakdown)
trial.set_user_attr("penalties", penalties)
log_line = format_trial_summary(summary)
logger.info(log_line)
@@ -1508,6 +1664,7 @@ class PipelineOptimizer:
"sharpe_median": None, "pf_median": None, "calmar_median": None,
"expectancy_median": None, "floating_dd_median": None,
"trades_median": 0, "mr_leak_count": 0,
"regime_occupancy": {}, "trade_distribution": {},
"activation_events": 0, "partial_close_events": 0,
"trail_updates": 0, "regime_shift_forced_closes": 0,
"avg_activation_pnl_usd": 0.0,
@@ -1523,6 +1680,39 @@ class PipelineOptimizer:
finally:
gc.collect()
def _cpcv_from_attrs(attrs: dict, final_score: float) -> dict:
"""Rebuild a CPCV payload from persisted Optuna trial attrs."""
return {
"cpcv_score": float(final_score),
"n_valid_paths": int(attrs.get("n_valid_paths", 0)),
"median_sharpe": float(attrs.get("median_sharpe", 0.0)),
"std_sharpe": float(attrs.get("std_sharpe", 0.0)),
"median_trades": int(attrs.get("median_trades", 0)),
"median_win_rate": float(attrs.get("median_win_rate", 0.0)),
"median_drawdown": float(attrs.get("median_drawdown", 0.0)),
"median_return": float(attrs.get("median_return", 0.0)),
"median_pf": float(attrs.get("median_pf", 1.0)),
"median_expectancy": float(attrs.get("median_expectancy", 0.0)),
"median_calmar": float(attrs.get("median_calmar", 0.0)),
"path_scores": list(attrs.get("path_scores", [])),
"path_sharpes": list(attrs.get("path_sharpes", [])),
"path_trades": list(attrs.get("path_trades", [])),
"path_winrates": list(attrs.get("path_winrates", [])),
"path_drawdowns": list(attrs.get("path_drawdowns", [])),
"path_returns": list(attrs.get("path_returns", [])),
"path_pfs": list(attrs.get("path_pfs", [])),
"path_expectancies": list(attrs.get("path_expectancies", [])),
"regime_occupancy": dict(attrs.get("regime_occupancy", {})),
"trade_distribution": dict(attrs.get("trade_distribution", {})),
"total_mr_leaks": int(attrs.get("mr_leak_count", 0)),
"total_activation_events": int(attrs.get("total_activation_events", 0)),
"total_partial_close_events": int(attrs.get("total_partial_close_events", 0)),
"total_regime_forced_closes": int(attrs.get("total_regime_forced_closes", 0)),
"total_trail_updates": int(attrs.get("total_trail_updates", 0)),
"avg_activation_pnl_usd": float(attrs.get("avg_activation_pnl_usd", 0.0)),
}
def run_optimization_stage1(
df: pd.DataFrame,
tf: str,
@@ -1623,6 +1813,19 @@ def run_optimization(
feature_cols = list(get_feature_cols(df))
_check_study_hash(study, tf=tf, broker=broker, feature_cols=feature_cols)
_existing_params: set[str] = set()
for _t in study.trials:
if _t.params:
_existing_params.update(str(k) for k in _t.params.keys())
if _existing_params:
try:
audit_search_space(_existing_params)
except RuntimeError as _audit_err:
raise RuntimeError(
f"Existing study contains forbidden/unknown params: {_audit_err}. "
"Reset the study (--reset_study) before continuing."
)
if warm_start_stage1:
logger.warning(
"warm_start_stage1 is deprecated and ignored in unified mode. "
@@ -1657,6 +1860,13 @@ def run_optimization(
tf=tf, broker=broker, account_size=account_size,
df=df, wfo_mode=wfo_mode,
)
_active_params = _pipeline_opt.active_param_names()
print("Active Optuna search space:")
print(search_space_as_text(_active_params))
audit_search_space(_active_params)
logger.info("Optuna search space audited: %s", _active_params)
study.optimize(
_pipeline_opt.objective,
n_trials=remaining,
@@ -1694,35 +1904,47 @@ def run_optimization(
+ "\n".join(f" <code>{k}</code>: {v}" for k, v in study.best_params.items())
)
# Persist best-trial CPCV stats so --mode report can display them directly.
# These are the ground-truth OOS metrics recorded during optimisation.
try:
import json as _json
from pathlib import Path as _Path
_tf_up = tf.upper()
_bt = study.best_trial
_attrs = _bt.user_attrs # {'median_sharpe', 'median_trades', 'n_valid_paths', 'std_sharpe'}
_cpcv_data = {
"cpcv_score": float(best),
"n_valid_paths": int(_attrs.get("n_valid_paths", 0)),
"median_sharpe": float(_attrs.get("median_sharpe", 0.0)),
"std_sharpe": float(_attrs.get("std_sharpe", 0.0)),
"median_trades": int(_attrs.get("median_trades", 0)),
# These aren't stored per-trial; leave as 0.0 to be filled by --mode wfa
"median_win_rate": 0.0,
"median_drawdown": 0.0,
"median_return": 0.0,
"path_scores": [],
}
os.makedirs("reports", exist_ok=True)
_cpcv_path = _Path(f"reports/cpcv_{_tf_up.lower()}_{broker}.json")
_cpcv_path.write_text(_json.dumps(_cpcv_data, indent=2))
logger.info(
"Best-trial CPCV stats saved → %s (median_sharpe=%.3f median_trades=%d)",
_cpcv_path, _cpcv_data["median_sharpe"], _cpcv_data["median_trades"],
_bt = study.best_trial
_attrs = _bt.user_attrs
_cpcv_data = _cpcv_from_attrs(_attrs, final_score=float(best))
_objective_breakdown = dict(_attrs.get("objective_breakdown", {}))
_penalties = dict(_attrs.get("penalties", {}))
_report = build_cpcv_report(
tf=tf,
broker=broker,
optimization_summary={
"study_name": name,
"best_trial": int(_bt.number),
"completed_trials": int(total_done),
"search_space": list(_active_params),
},
cpcv_result=_cpcv_data,
objective_breakdown=_objective_breakdown,
final_score=float(best),
penalties=_penalties,
)
_path_main = write_cpcv_report(_report, tf=tf, output_dir="reports")
_path_legacy = write_legacy_cpcv_report(_report, tf=tf, broker=broker, output_dir="reports")
logger.info("Unified CPCV report saved → %s", _path_main)
logger.info("Legacy CPCV report mirror saved → %s", _path_legacy)
_occ = _cpcv_data.get("regime_occupancy", {})
_trd = _cpcv_data.get("trade_distribution", {})
print(f"\n{tf.upper()}")
print(f"TREND: {float(_occ.get('TREND', 0.0)):.1f}%")
print(f"MEAN_REVERSION: {float(_occ.get('MEAN_REVERSION', 0.0)):.1f}%")
print(f"VOLATILITY_SHOCK: {float(_occ.get('VOLATILITY_SHOCK', 0.0)):.1f}%")
print("\nTrades:")
print(f"Trend = {int(_trd.get('TREND', 0))}")
print(f"Shock = {int(_trd.get('VOLATILITY_SHOCK', 0))}")
_mr_tr = int(_trd.get("MEAN_REVERSION", 0))
print(f"MR = {_mr_tr}")
if _mr_tr > 0:
print("DEFECT: MR trades are non-zero.")
except Exception as _save_err:
logger.warning("Could not save CPCV stats after optimisation: %s", _save_err)
logger.warning("Could not save unified CPCV report after optimisation: %s", _save_err)
return study
@@ -1739,9 +1961,6 @@ def save_best_trial_cpcv(
Used to backfill the CPCV JSON from an existing completed study without
re-running optimisation. Returns True on success, False if study is empty.
"""
import json as _json
from pathlib import Path as _Path
tier = _get_tier(balance)
name = _study_name(broker=broker, tier=tier, tf=tf)
storage = _study_db(broker)
@@ -1758,20 +1977,23 @@ def save_best_trial_cpcv(
_bt = _study.best_trial
_attrs = _bt.user_attrs
_data = {
"cpcv_score": float(_study.best_value),
"n_valid_paths": int(_attrs.get("n_valid_paths", 0)),
"median_sharpe": float(_attrs.get("median_sharpe", 0.0)),
"std_sharpe": float(_attrs.get("std_sharpe", 0.0)),
"median_trades": int(_attrs.get("median_trades", 0)),
"median_win_rate": 0.0,
"median_drawdown": 0.0,
"median_return": 0.0,
"path_scores": [],
}
os.makedirs("reports", exist_ok=True)
_path = _Path(f"reports/cpcv_{tf.lower()}_{broker}.json")
_path.write_text(_json.dumps(_data, indent=2))
_data = _cpcv_from_attrs(_attrs, final_score=float(_study.best_value))
_report = build_cpcv_report(
tf=tf,
broker=broker,
optimization_summary={
"study_name": _study.study_name,
"best_trial": int(_bt.number),
"completed_trials": len([t for t in _study.trials if t.state == optuna.trial.TrialState.COMPLETE]),
"search_space": sorted(list(_bt.params.keys())),
},
cpcv_result=_data,
objective_breakdown=dict(_attrs.get("objective_breakdown", {})),
final_score=float(_study.best_value),
penalties=dict(_attrs.get("penalties", {})),
)
_path = write_cpcv_report(_report, tf=tf, output_dir="reports")
write_legacy_cpcv_report(_report, tf=tf, broker=broker, output_dir="reports")
logger.info(
"CPCV stats saved from best trial → %s (score=%.3f median_sharpe=%.3f trades=%d)",
_path, _data["cpcv_score"], _data["median_sharpe"], _data["median_trades"],
@@ -1973,21 +2195,24 @@ def run_wfa(
result["wfe_ratio"] = 0.0 # not applicable in CPCV
result["window_scores"] = result.get("path_scores", [])
# Persist CPCV summary so --mode report can load it as primary OOS metrics
import json as _json
from pathlib import Path as _Path
_cpcv_path = _Path(f"reports/cpcv_{tf.lower()}_{broker}.json")
_cpcv_path.parent.mkdir(parents=True, exist_ok=True)
_saveable = {k: v for k, v in result.items() if k != "params"}
# Convert any numpy types to native Python for JSON serialisation
def _to_native(obj):
if isinstance(obj, (np.integer,)): return int(obj)
if isinstance(obj, (np.floating,)): return float(obj)
if isinstance(obj, np.ndarray): return obj.tolist()
if isinstance(obj, list): return [_to_native(x) for x in obj]
return obj
_cpcv_path.write_text(_json.dumps({k: _to_native(v) for k, v in _saveable.items()}, indent=2))
logger.info("CPCV report saved → %s", _cpcv_path)
_report = build_cpcv_report(
tf=tf,
broker=broker,
optimization_summary={
"study_name": f"wfa_{tf.lower()}_{broker}",
"best_trial": None,
"completed_trials": None,
"search_space": [],
},
cpcv_result=result,
objective_breakdown={"weights": {}, "components": {}, "base_score": float(result.get("cpcv_score", 0.0))},
final_score=float(result.get("cpcv_score", 0.0)),
penalties={},
)
_path_main = write_cpcv_report(_report, tf=tf, output_dir="reports")
_path_legacy = write_legacy_cpcv_report(_report, tf=tf, broker=broker, output_dir="reports")
logger.info("CPCV report saved → %s", _path_main)
logger.info("CPCV legacy mirror saved → %s", _path_legacy)
return result
@@ -2045,7 +2270,7 @@ def execute_cpcv(
embargo_bars: int = 24,
trial_number: int | None = None,
hmm_params: dict | None = None,
h1_gates: dict | None = None,
signal_cfg: dict | None = None,
) -> dict:
"""Execute Combinatorial Purged CV with strict per-path HMM and Scaler fitting.
@@ -2073,6 +2298,12 @@ def execute_cpcv(
path_pfs: list = []
path_expectancies: list = []
path_mr_leaks: list = []
path_occ_trend: list = []
path_occ_mr: list = []
path_occ_shock: list = []
path_trd_trend: list = []
path_trd_mr: list = []
path_trd_shock: list = []
# lifecycle parity telemetry accumulators
path_activation_events: list = []
path_partial_close_events: list = []
@@ -2082,9 +2313,15 @@ def execute_cpcv(
n_valid_paths = 0
min_trades = HARD_TRADE_FLOORS.get(tf.upper(), 10)
# H1 entry prob override: apply per-path via evaluator_config
_h1_ep = float((h1_gates or {}).get("h1_entry_prob", 0.0))
_h1_eval_cfg = {"h1_entry_prob": _h1_ep} if (tf.upper() == "H1" and _h1_ep > 0) else None
# Entry probability threshold override for policy path.
_entry_prob = (signal_cfg or {}).get("entry_probability_threshold")
_eval_cfg = None
if _entry_prob is not None:
_eval_cfg = {
"entry_probability_threshold": float(_entry_prob),
# Backward-compat alias consumed by existing backtester wiring.
"h1_entry_prob": float(_entry_prob),
}
for path_idx, (train_mask, test_mask) in enumerate(splitter.split()):
try:
@@ -2172,7 +2409,7 @@ def execute_cpcv(
test_df, test_probs, test_states,
split_idx=None, account_size=balance, broker=broker, tf=tf,
hmm_transmat=model_path.transmat_,
evaluator_config=_h1_eval_cfg,
evaluator_config=_eval_cfg,
)
n_trades = int(path_result.get("n_trades", 0))
@@ -2216,6 +2453,15 @@ def execute_cpcv(
path_expectancies.append(float(path_result.get("expected_payoff", 0.0)))
path_mr_leaks.append(int(path_result.get("mr_leak_count",
path_result.get("mr_trades", 0))))
_occ = path_result.get("regime_occupancy", {})
path_occ_trend.append(float(_occ.get("TREND", 0.0)))
path_occ_mr.append(float(_occ.get("MEAN_REVERSION", 0.0)))
path_occ_shock.append(float(_occ.get("VOLATILITY_SHOCK", 0.0)))
_dist = path_result.get("trade_distribution", {})
path_trd_trend.append(int(_dist.get("TREND", 0)))
path_trd_mr.append(int(_dist.get("MEAN_REVERSION", 0)))
path_trd_shock.append(int(_dist.get("VOLATILITY_SHOCK", 0)))
# lifecycle counters
path_activation_events.append(int(path_result.get("activation_events", 0)))
path_partial_close_events.append(int(path_result.get("partial_close_events", 0)))
@@ -2270,6 +2516,17 @@ def execute_cpcv(
for r, d in zip(path_returns, path_drawdowns)
]
regime_occupancy = {
"TREND": float(np.median(path_occ_trend)) if path_occ_trend else 0.0,
"MEAN_REVERSION": float(np.median(path_occ_mr)) if path_occ_mr else 0.0,
"VOLATILITY_SHOCK": float(np.median(path_occ_shock)) if path_occ_shock else 0.0,
}
trade_distribution = {
"TREND": int(np.median(path_trd_trend)) if path_trd_trend else 0,
"MEAN_REVERSION": int(np.median(path_trd_mr)) if path_trd_mr else 0,
"VOLATILITY_SHOCK": int(np.median(path_trd_shock)) if path_trd_shock else 0,
}
return {
"cpcv_score": final_score,
"n_valid_paths": n_valid_paths,
@@ -2285,6 +2542,8 @@ def execute_cpcv(
np.median(_path_calmars) if _path_calmars else 0.0, -5.0, 5.0
)),
"total_mr_leaks": int(sum(path_mr_leaks)),
"regime_occupancy": regime_occupancy,
"trade_distribution": trade_distribution,
# lifecycle parity telemetry
"total_activation_events": int(sum(path_activation_events)),
"total_partial_close_events": int(sum(path_partial_close_events)),
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Iterable
APPROVED_PARAMS: set[str] = {
"obs_cov",
"trans_cov",
"persistence_threshold",
"max_depth",
"learning_rate",
"subsample",
"colsample_bytree",
"min_child_weight",
"gamma",
"n_estimators",
"reg_alpha",
"reg_lambda",
"scale_pos_weight",
"entry_probability_threshold",
"confirmation_bars",
"atr_stop_multiplier",
"atr_target_multiplier",
}
FORBIDDEN_PARAMS: set[str] = {
"h1_min_median_sharpe",
"h1_min_median_pf",
"h1_max_trades_per_100",
"m15_min_median_sharpe",
"m15_min_median_pf",
"m15_max_trades_per_100",
"m5_min_median_sharpe",
"m5_min_median_pf",
"m5_max_trades_per_100",
}
def audit_search_space(param_names: Iterable[str]) -> None:
names = sorted({str(p) for p in param_names})
forbidden = [p for p in names if p in FORBIDDEN_PARAMS]
if forbidden:
raise RuntimeError(f"Forbidden Optuna parameters detected: {forbidden}")
unknown = [p for p in names if p not in APPROVED_PARAMS]
if unknown:
raise RuntimeError(f"Unknown Optuna parameters detected: {unknown}")
def search_space_as_text(param_names: Iterable[str]) -> str:
names = sorted({str(p) for p in param_names})
return "\n".join(f" - {name}" for name in names)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
@dataclass
class RegimeStats:
bars: int
percentage: float
avg_return: float
volatility: float
avg_duration: float
transition_in: int
transition_out: int
def _state_runs(states: np.ndarray, state_id: int) -> list[int]:
idx = np.where(states == state_id)[0]
if len(idx) == 0:
return []
runs: list[int] = []
start = int(idx[0])
prev = int(idx[0])
for i in idx[1:]:
ii = int(i)
if ii == prev + 1:
prev = ii
continue
runs.append(prev - start + 1)
start = ii
prev = ii
runs.append(prev - start + 1)
return runs
def summarize_regimes(
states: np.ndarray,
returns: np.ndarray,
state_labels: dict[int, str],
) -> dict[str, dict]:
states_arr = np.asarray(states)
returns_arr = np.asarray(returns)
n = int(min(len(states_arr), len(returns_arr)))
if n == 0:
return {
name: asdict(
RegimeStats(
bars=0,
percentage=0.0,
avg_return=0.0,
volatility=0.0,
avg_duration=0.0,
transition_in=0,
transition_out=0,
)
)
for _, name in sorted(state_labels.items())
}
states_arr = states_arr[:n]
returns_arr = returns_arr[:n]
out: dict[str, dict] = {}
for sid, name in sorted(state_labels.items()):
idx = np.where(states_arr == sid)[0]
bars = int(len(idx))
pct = float(bars / max(n, 1))
rets = returns_arr[idx] if bars else np.array([])
avg_ret = float(np.mean(rets)) if bars else 0.0
vol = float(np.std(rets)) if bars else 0.0
runs = _state_runs(states_arr, sid)
avg_dur = float(np.mean(runs)) if runs else 0.0
trans_in = 0
trans_out = 0
for i in range(1, n):
if states_arr[i] == sid and states_arr[i - 1] != sid:
trans_in += 1
if states_arr[i] != sid and states_arr[i - 1] == sid:
trans_out += 1
out[name] = asdict(
RegimeStats(
bars=bars,
percentage=round(100.0 * pct, 3),
avg_return=avg_ret,
volatility=vol,
avg_duration=avg_dur,
transition_in=int(trans_in),
transition_out=int(trans_out),
)
)
return out
def summarize_trade_distribution(
signals: np.ndarray,
states: np.ndarray,
state_labels: dict[int, str],
) -> dict[str, int]:
sig = np.asarray(signals)
st = np.asarray(states)
n = int(min(len(sig), len(st)))
if n == 0:
return {name: 0 for _, name in sorted(state_labels.items())}
sig = sig[:n]
st = st[:n]
prev = np.concatenate([[0], sig[:-1]])
is_entry = (sig != 0) & (sig != prev)
out = {name: 0 for _, name in sorted(state_labels.items())}
for i in np.where(is_entry)[0]:
sid = int(st[i])
label = state_labels.get(sid, f"state_{sid}")
out[label] = int(out.get(label, 0) + 1)
return out
def write_regime_diagnostics(
tf: str,
states: np.ndarray,
returns: np.ndarray,
state_labels: dict[int, str],
output_dir: str | Path = "reports",
) -> Path:
report = {
"timeframe": tf.upper(),
"regimes": summarize_regimes(states, returns, state_labels),
}
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"regime_diagnostics_{tf.upper()}.json"
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
return out_path
def get_occupancy_percentage(summary: dict[str, dict], label: str) -> float:
row = summary.get(label, {}) if isinstance(summary, dict) else {}
return float(row.get("percentage", 0.0)) / 100.0
+176 -50
View File
@@ -5,7 +5,7 @@ exits on regime reversal, persistence collapse, profit erosion, or max hold.
Replaces Z-Score and RCEV evaluation logic.
"""
from dataclasses import dataclass
from dataclasses import dataclass, field
import numpy as np
@@ -26,15 +26,15 @@ SHOCK_STATE = CANONICAL_REGIME_ID[REGIME_SHOCK] # 2
# This is the single canonical source of state-to-trade permission.
# Minimum consecutive bars in a regime before entry is allowed.
MIN_CONFIRMATION_BARS = {"H1": 2, "M15": 2, "M5": 2}
MIN_CONFIRMATION_BARS = {"H1": 4, "M15": 3, "M5": 2}
# Minimum consecutive bars in a NEW (different) regime before regime-reversal
# exit fires.
MIN_EXIT_CONFIRM_BARS = {"H1": 2, "M15": 2, "M5": 2}
# XGBoost probability threshold for trend and SHOCK entries.
# H1: probability check required. M15/M5: bypass (XGB probs cluster near 50-55%).
ENTRY_PROB = {"H1": 0.575}
# M5 keeps a dynamic rolling percentile gate in evaluate_bar().
ENTRY_PROB = {"H1": 0.58, "M15": 0.53}
# Z-Score thresholds for TREND and SHOCK entries.
MIN_TREND_ZSCORE = {"H1": 0.5, "M15": 1.0, "M5": 1.5}
@@ -71,14 +71,13 @@ MR_SIZE_MULTIPLIER = 0.75
def should_apply_prob_gate(tf: str) -> bool:
"""Return True when the XGBoost probability gate should be enforced.
H1: probability check required (XGB signal is meaningful at hourly scale).
M15/M5: bypass — XGB probabilities cluster near 50-55% on noisy short TFs;
strict gates produce a dead zone that suppresses valid signals.
H1/M15: fixed threshold gate required.
M5: dynamic rolling percentile gate handled separately.
This single function is the canonical source used by both backtester and
live trader, eliminating ad hoc per-module guards.
"""
return tf.upper() == "H1"
return tf.upper() in ENTRY_PROB
@dataclass
@@ -88,6 +87,7 @@ class SignalDecision:
Both backtest and live paths consume this object, ensuring identical
policy logic regardless of execution environment.
"""
action: str
enter: bool
exit: bool
direction: int # +1 = long, -1 = short, 0 = flat
@@ -97,6 +97,77 @@ class SignalDecision:
position_size_multiplier: float
sl_atr_multiple: float
tp_atr_multiple: float
metadata: dict = field(default_factory=dict)
@classmethod
def no_trade(
cls,
confidence: float,
regime: str,
reason: str,
metadata: dict | None = None,
) -> "SignalDecision":
return cls(
action="NO_TRADE",
enter=False,
exit=False,
direction=0,
confidence=float(confidence),
regime=regime,
reason=reason,
metadata=metadata or {},
position_size_multiplier=1.0,
sl_atr_multiple=0.0,
tp_atr_multiple=0.0,
)
@classmethod
def exit_trade(
cls,
confidence: float,
regime: str,
reason: str,
metadata: dict | None = None,
) -> "SignalDecision":
return cls(
action="EXIT",
enter=False,
exit=True,
direction=0,
confidence=float(confidence),
regime=regime,
reason=reason,
metadata=metadata or {},
position_size_multiplier=1.0,
sl_atr_multiple=0.0,
tp_atr_multiple=0.0,
)
@classmethod
def enter_trade(
cls,
direction: int,
confidence: float,
regime: str,
reason: str,
size_multiplier: float,
sl_atr_multiple: float,
tp_atr_multiple: float,
metadata: dict | None = None,
) -> "SignalDecision":
return cls(
action="ENTER_LONG" if direction > 0 else "ENTER_SHORT",
enter=True,
exit=False,
direction=int(direction),
confidence=float(confidence),
regime=regime,
reason=reason,
metadata=metadata or {},
position_size_multiplier=float(size_multiplier),
sl_atr_multiple=float(sl_atr_multiple),
tp_atr_multiple=float(tp_atr_multiple),
)
class SignalEngine:
@@ -154,6 +225,15 @@ class SignalEngine:
regime_label = self._REGIME_LABELS.get(state, f"state_{state}")
regime_info = self.update_regime(state, hmm_transmat)
base_meta = {
"tf": self.tf,
"state": state,
"bars_in_regime": int(regime_info.get("bars_in_regime", 0)),
"stability": float(regime_info.get("stability", 0.0)),
"probability": prob,
"synth_vix_zscore": vix_z,
"atr_band_position": atr_bnd,
}
# ── Exit check first (if in a position) ───────────────────────────────
if current_position:
@@ -164,22 +244,53 @@ class SignalEngine:
regime_info, cur_pnl, max_fav_pnl, bars_in_trade
)
if exit_now:
return SignalDecision(
enter=False, exit=True, direction=0, confidence=prob,
regime=regime_label, reason=reason,
position_size_multiplier=1.0,
sl_atr_multiple=0.0, tp_atr_multiple=0.0,
exit_meta = dict(base_meta)
exit_meta.update({
"cur_pnl": cur_pnl,
"max_fav_pnl": max_fav_pnl,
"bars_in_trade": bars_in_trade,
})
return SignalDecision.exit_trade(
confidence=prob,
regime=regime_label,
reason=reason,
metadata=exit_meta,
)
# ── Entry check (if flat) ──────────────────────────────────────────────
if not current_position:
# Strict STATE_POLICY: MEAN_REVERSION is always no-trade.
if not STATE_POLICY.get(regime_label, False):
return SignalDecision(
enter=False, exit=False, direction=0, confidence=prob,
regime=regime_label, reason="state_disabled",
position_size_multiplier=1.0, sl_atr_multiple=0.0, tp_atr_multiple=0.0,
# Hard block: MEAN_REVERSION always returns no-trade before other filters.
if regime_label == REGIME_MR:
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="mean_reversion_state",
metadata=base_meta,
)
# Strict STATE_POLICY fallback for unknown labels.
if not STATE_POLICY.get(regime_label, False):
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="state_disabled",
metadata=base_meta,
)
if should_apply_prob_gate(self.tf):
_min_prob = float(ENTRY_PROB.get(self.tf, 0.0))
_confidence = max(prob, 1.0 - prob)
if _confidence < _min_prob:
gate_meta = dict(base_meta)
gate_meta["required_confidence"] = _min_prob
gate_meta["directional_confidence"] = _confidence
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="probability_below_threshold",
metadata=gate_meta,
)
# Rolling probability window for M5 dynamic threshold.
self._probs_window.append(prob)
if len(self._probs_window) > 200:
@@ -191,16 +302,22 @@ class SignalEngine:
np.array(self._probs_window), percentile=90.0
)
if prob < _dyn_th:
return SignalDecision(
enter=False, exit=False, direction=0, confidence=prob,
regime=regime_label, reason="m5_prob_below_dynamic_threshold",
position_size_multiplier=1.0, sl_atr_multiple=0.0, tp_atr_multiple=0.0,
dyn_meta = dict(base_meta)
dyn_meta["dynamic_threshold"] = _dyn_th
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="m5_prob_below_dynamic_threshold",
metadata=dyn_meta,
)
entry = self.should_enter(regime_info, prob, vix_z, atr_bnd,
z_cutoff_bull=tf_config.get("z_cutoff_bull"),
z_cutoff_bear=tf_config.get("z_cutoff_bear"),
h1_entry_prob=tf_config.get("h1_entry_prob"))
entry_probability_threshold=tf_config.get(
"entry_probability_threshold",
tf_config.get("h1_entry_prob"),
))
# Phase 3 — M15: continuation quality gate.
# Neutral fallbacks (1.0) pass the gate when features are absent
@@ -217,10 +334,11 @@ class SignalEngine:
"realized_vol_ratio": float(row.get("realized_vol_ratio", 1.0)),
}
if not self._m15_entry_ok(_m15_row, tf_config):
return SignalDecision(
enter=False, exit=False, direction=0, confidence=prob,
regime=regime_label, reason="m15_gate_rejected",
position_size_multiplier=1.0, sl_atr_multiple=0.0, tp_atr_multiple=0.0,
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="m15_gate_rejected",
metadata=base_meta,
)
# Phase 4 — meta-label gate (no-op unless use_meta_label=True in tf_config).
@@ -233,30 +351,39 @@ class SignalEngine:
_meta_X = _pd.DataFrame([row])
_meta_prob = float(_meta_model.predict_proba(_meta_X)[0, 1])
if _meta_prob < float(tf_config.get("meta_min_prob", 0.55)):
return SignalDecision(
enter=False, exit=False, direction=0, confidence=prob,
regime=regime_label, reason="meta_label_reject",
position_size_multiplier=1.0, sl_atr_multiple=0.0, tp_atr_multiple=0.0,
meta_reject = dict(base_meta)
meta_reject["meta_probability"] = _meta_prob
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="meta_label_reject",
metadata=meta_reject,
)
if entry:
sig = entry["signal"]
direction = 1 if sig == "BUY" else -1
return SignalDecision(
enter=True, exit=False,
enter_meta = dict(base_meta)
enter_meta.update({
"entry_regime": entry.get("regime"),
"size_multiplier": float(entry.get("size_multiplier", 1.0)),
})
return SignalDecision.enter_trade(
direction=direction,
confidence=prob,
regime=regime_label,
reason=f"entry_{entry.get('regime', 'unknown').lower()}_{sig.lower()}",
position_size_multiplier=entry.get("size_multiplier", 1.0),
size_multiplier=entry.get("size_multiplier", 1.0),
sl_atr_multiple=tf_config.get("sl_atr_multiple", 2.0),
tp_atr_multiple=tf_config.get("tp_atr_multiple", 1.5),
metadata=enter_meta,
)
return SignalDecision(
enter=False, exit=False, direction=0, confidence=prob,
regime=regime_label, reason="no_signal",
position_size_multiplier=1.0, sl_atr_multiple=0.0, tp_atr_multiple=0.0,
return SignalDecision.no_trade(
confidence=prob,
regime=regime_label,
reason="no_signal",
metadata=base_meta,
)
# ── Core API ──────────────────────────────────────────────────────────────
@@ -303,7 +430,7 @@ class SignalEngine:
atr_band_position: float,
z_cutoff_bull: float | None = None,
z_cutoff_bear: float | None = None,
h1_entry_prob: float | None = None,
entry_probability_threshold: float | None = None,
) -> dict | None:
"""Evaluate entry conditions for the current bar.
@@ -332,18 +459,17 @@ class SignalEngine:
eff_buy = xgb_prob
eff_sell = 1.0 - xgb_prob
# H1 probability gate: use tunable per-run threshold when provided,
# otherwise fall back to ENTRY_PROB constant.
# M15/M5: bypass (XGB probs cluster near 50-55%).
if self.tf == "H1":
buy_thr, sell_thr = self._effective_h1_prob_thresholds(
{"h1_entry_prob": h1_entry_prob} if h1_entry_prob is not None else None
if should_apply_prob_gate(self.tf):
thr = (
float(entry_probability_threshold)
if entry_probability_threshold is not None
else float(ENTRY_PROB.get(self.tf, 0.0))
)
prob_req_buy = eff_buy >= buy_thr
prob_req_sell = eff_sell >= sell_thr
prob_req_buy = eff_buy >= thr
prob_req_sell = eff_sell >= thr
else:
prob_req_buy = eff_buy >= ENTRY_PROB.get(self.tf, 0.0)
prob_req_sell = eff_sell >= ENTRY_PROB.get(self.tf, 0.0)
prob_req_buy = True
prob_req_sell = True
# Effective Z thresholds — caller override takes precedence
trend_z_bull = (float(z_cutoff_bull) if z_cutoff_bull is not None