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