mirror of
https://github.com/lucasmos/GoldRegime_X.git
synced 2026-11-02 06:26:15 +00:00
feat: Enhance CPCV metrics handling and reporting across multiple modules
This commit is contained in:
@@ -138,6 +138,7 @@ def _train_for_tf(tf: str, balance: float, broker: str, params: dict):
|
||||
account_size=balance,
|
||||
broker=broker,
|
||||
tf=tf,
|
||||
hmm_transmat=model_hmm.transmat_, # use real persistence values, consistent with optimizer CPCV
|
||||
)
|
||||
return result, model_hmm, state_map, models_ensemble, thresholds, metrics, df_aligned, states_aligned, X, probabilities
|
||||
|
||||
@@ -692,21 +693,50 @@ def cmd_report(args):
|
||||
_hs_is = states_aligned[:len(_X_is)]
|
||||
metrics["regime_stats"] = compute_regime_stats(models_xgb, thresholds_xgb, _X_is, _hs_is)
|
||||
|
||||
# split_idx in saved metrics may come from a different TF's training run
|
||||
# (e.g. M15 split_idx ~186k used in an H1 report with only ~58k bars).
|
||||
# Recompute from current data when the stored value doesn't fit.
|
||||
# split_idx=None means the model was trained with train_ratio=1.0 (full-data /
|
||||
# CPCV mode). In that case keep split_idx=None so the report backtest is
|
||||
# also full-period — matching the training output exactly.
|
||||
# Only fall back to 80/20 when a stored split_idx exists but doesn't fit the
|
||||
# current data (e.g. loaded from a model trained on a different TF).
|
||||
split_idx = metrics.get("split_idx")
|
||||
if split_idx is None or not (0 < split_idx < len(X)):
|
||||
if split_idx is not None and not (0 < split_idx < len(X)):
|
||||
split_idx = int(len(X) * 0.8)
|
||||
|
||||
result = vectorized_backtest(
|
||||
df_aligned, probabilities, states_aligned,
|
||||
split_idx=split_idx, account_size=balance, broker=broker, tf=tf,
|
||||
regime_stats=metrics.get("regime_stats"),
|
||||
evaluator_config=({"Z_CUTOFF_BULL": params["z_cutoff_bull"], "Z_CUTOFF_BEAR": -params["z_cutoff_bull"]}
|
||||
if params.get("z_cutoff_bull") else None),
|
||||
hmm_transmat=model_hmm.transmat_, # use real persistence values, consistent with optimizer CPCV
|
||||
)
|
||||
|
||||
# ── Inject CPCV OOS stats from last --mode optimize run if available ─────
|
||||
_cpcv_json = Path(f"reports/cpcv_{tf.lower()}_{broker}.json")
|
||||
if not _cpcv_json.exists():
|
||||
# Try to backfill from the existing Optuna study DB
|
||||
try:
|
||||
from src.optimizer import save_best_trial_cpcv
|
||||
save_best_trial_cpcv(broker=broker, tf=tf, balance=balance)
|
||||
except Exception as _be:
|
||||
logger.warning("Could not backfill CPCV JSON from study: %s", _be)
|
||||
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)
|
||||
logger.info(
|
||||
"Loaded CPCV OOS stats from %s — median_sharpe=%.3f median_trades=%d",
|
||||
_cpcv_json, result["oos_sharpe_ratio"], result["oos_n_trades"],
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning("Could not load CPCV JSON (%s): %s", _cpcv_json, _e)
|
||||
|
||||
arm = AdaptiveRiskManager(balance, broker=broker)
|
||||
limits = arm.get_trade_limits()
|
||||
display_params = dict(params)
|
||||
|
||||
+2
-2
@@ -403,8 +403,8 @@ def _run_bar_loop(
|
||||
tf: str,
|
||||
broker: str,
|
||||
account_size: float,
|
||||
split_idx,
|
||||
return_trades: bool,
|
||||
split_idx=None,
|
||||
return_trades: bool = False,
|
||||
test_mask=None,
|
||||
raw_atr_arr=None,
|
||||
) -> tuple:
|
||||
|
||||
+123
-9
@@ -1112,11 +1112,91 @@ 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"],
|
||||
)
|
||||
except Exception as _save_err:
|
||||
logger.warning("Could not save CPCV stats after optimisation: %s", _save_err)
|
||||
|
||||
return study
|
||||
|
||||
|
||||
# ── Param extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
def save_best_trial_cpcv(
|
||||
broker: str,
|
||||
tf: str,
|
||||
balance: float = 15.0,
|
||||
) -> bool:
|
||||
"""Extract and save the best trial's CPCV stats to reports/cpcv_{tf}_{broker}.json.
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
_study = optuna.load_study(study_name=name, storage=storage)
|
||||
except Exception as _e:
|
||||
logger.warning("Could not load study '%s': %s", name, _e)
|
||||
return False
|
||||
|
||||
if not _study.best_trial:
|
||||
logger.warning("Study '%s' has no complete trials.", name)
|
||||
return False
|
||||
|
||||
_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))
|
||||
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"],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_best_params(balance: float = 15.0, broker: str = "standard", tf: str = "H1") -> dict:
|
||||
"""Load best hyperparameters from the persisted SQLite study.
|
||||
|
||||
@@ -1280,6 +1360,23 @@ def run_wfa(
|
||||
result["n_valid_windows"] = result["n_valid_paths"]
|
||||
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)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1352,9 +1449,12 @@ def execute_cpcv(
|
||||
purge_bars=embargo_bars,
|
||||
)
|
||||
|
||||
path_scores: list = []
|
||||
path_sharpes: list = []
|
||||
path_trades: list = []
|
||||
path_scores: list = []
|
||||
path_sharpes: list = []
|
||||
path_trades: list = []
|
||||
path_winrates: list = []
|
||||
path_drawdowns: list = []
|
||||
path_returns: list = []
|
||||
n_valid_paths = 0
|
||||
min_trades = HARD_TRADE_FLOORS.get(tf.upper(), 10)
|
||||
|
||||
@@ -1439,12 +1539,18 @@ def execute_cpcv(
|
||||
path_scores.append(-50.0)
|
||||
path_sharpes.append(-10.0)
|
||||
path_trades.append(n_trades)
|
||||
path_winrates.append(0.0)
|
||||
path_drawdowns.append(float(fdd))
|
||||
path_returns.append(0.0)
|
||||
continue
|
||||
|
||||
n_valid_paths += 1
|
||||
path_scores.append(_score_result(path_result, broker=broker, tf=tf))
|
||||
path_sharpes.append(path_result.get("sharpe_ratio", 0.0))
|
||||
path_trades.append(n_trades)
|
||||
path_winrates.append(float(path_result.get("win_rate", 0.0)))
|
||||
path_drawdowns.append(float(fdd))
|
||||
path_returns.append(float(path_result.get("total_return", 0.0)))
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("CPCV path %d failed: %s", path_idx + 1, exc)
|
||||
@@ -1475,12 +1581,20 @@ def execute_cpcv(
|
||||
final_score,
|
||||
)
|
||||
return {
|
||||
"cpcv_score": final_score,
|
||||
"n_valid_paths": n_valid_paths,
|
||||
"median_sharpe": float(np.median(path_sharpes)),
|
||||
"std_sharpe": float(np.std(path_sharpes)),
|
||||
"median_trades": int(np.median(path_trades) if path_trades else 0),
|
||||
"path_scores": path_scores,
|
||||
"cpcv_score": final_score,
|
||||
"n_valid_paths": n_valid_paths,
|
||||
"median_sharpe": float(np.median(path_sharpes)),
|
||||
"std_sharpe": float(np.std(path_sharpes)),
|
||||
"median_trades": int(np.median(path_trades) if path_trades else 0),
|
||||
"median_win_rate": float(np.median(path_winrates) if path_winrates else 0.0),
|
||||
"median_drawdown": float(np.median(path_drawdowns) if path_drawdowns else 0.0),
|
||||
"median_return": float(np.median(path_returns) if path_returns else 0.0),
|
||||
"path_scores": path_scores,
|
||||
"path_sharpes": path_sharpes,
|
||||
"path_trades": path_trades,
|
||||
"path_winrates": path_winrates,
|
||||
"path_drawdowns": path_drawdowns,
|
||||
"path_returns": path_returns,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ MIN_EXIT_CONFIRM_BARS = {"H1": 2, "M15": 2, "M5": 3}
|
||||
# Thresholds are deliberately strict: XGB test accuracy is 50-52% (next-bar
|
||||
# direction), so only the highest-confidence bars have real signal. Allowing
|
||||
# lower thresholds floods the engine with noise trades and destroys performance.
|
||||
ENTRY_PROB = {"H1": 0.54, "M15": 0.55, "M5": 0.52}
|
||||
ENTRY_PROB = {"H1": 0.55, "M15": 0.55, "M5": 0.52}
|
||||
MR_ENTRY_PROB = {"H1": 0.56, "M15": 0.52, "M5": 0.50}
|
||||
|
||||
# Maximum bars to hold a single trade before forcing exit
|
||||
|
||||
+16
-10
@@ -389,7 +389,8 @@ def plot_summary_dashboard(result, params, tf="H1", broker="headway_cent",
|
||||
rf = result.get("recovery_factor", 0.0)
|
||||
pf = result.get("profit_factor", 1.0)
|
||||
|
||||
ax.text(0.05, 0.83, "Performance (Full Period)", fontsize=13, fontweight="bold",
|
||||
_period_label = "Performance (Full Period IS)" if "cpcv_score" in result else "Performance (Full Period)"
|
||||
ax.text(0.05, 0.83, _period_label, fontsize=13, fontweight="bold",
|
||||
transform=ax.transAxes, va="top", color="#2c3e50")
|
||||
metrics_data = [
|
||||
("Sharpe Ratio", f"{sharpe:.3f}", "#2ecc71" if sharpe >= 1.0 else "#e67e22"),
|
||||
@@ -411,24 +412,30 @@ def plot_summary_dashboard(result, params, tf="H1", broker="headway_cent",
|
||||
# ── OOS metrics ──────────────────────────────────────────────────────────
|
||||
if "oos_sharpe_ratio" in result:
|
||||
y -= 0.01
|
||||
ax.text(0.05, y, "Out-of-Sample", fontsize=12, fontweight="bold",
|
||||
_is_cpcv = "cpcv_score" in result
|
||||
_oos_label = (
|
||||
f"CPCV OOS ({result.get('cpcv_n_valid_paths', '?')}/{6} valid paths)"
|
||||
if _is_cpcv else "Out-of-Sample"
|
||||
)
|
||||
ax.text(0.05, y, _oos_label, fontsize=12, fontweight="bold",
|
||||
transform=ax.transAxes, va="top", color="#c0392b")
|
||||
y -= 0.06
|
||||
oos_fdd = result.get("oos_floating_max_drawdown", result.get("oos_max_drawdown", 0.0))
|
||||
oos_fdd_usd = oos_fdd * account_size
|
||||
oos_data = [
|
||||
("Sharpe", f"{result['oos_sharpe_ratio']:.3f}"),
|
||||
("Max DD", f"{oos_fdd*100:.1f}%"),
|
||||
("Sharpe (median)", f"{result['oos_sharpe_ratio']:.3f}"),
|
||||
("Std Sharpe", f"{result.get('cpcv_std_sharpe', 0.0):.3f}" if _is_cpcv else "N/A"),
|
||||
("Max DD (median)", f"{oos_fdd*100:.1f}%"),
|
||||
("Max Monetary Risk",f"${oos_fdd_usd:.2f} USD"),
|
||||
("Win Rate", f"{result['oos_win_rate']*100:.1f}%"),
|
||||
("Trades", f"{result['oos_n_trades']}"),
|
||||
("Win Rate (median)",f"{result['oos_win_rate']*100:.1f}%"),
|
||||
("Trades (median)", f"{result['oos_n_trades']}"),
|
||||
]
|
||||
for label, value in oos_data:
|
||||
ax.text(0.07, y, label, fontsize=10, transform=ax.transAxes, va="top",
|
||||
fontfamily="monospace", color="#7f8c8d")
|
||||
ax.text(0.25, y, value, fontsize=10, transform=ax.transAxes, va="top",
|
||||
ax.text(0.30, y, value, fontsize=10, transform=ax.transAxes, va="top",
|
||||
fontfamily="monospace", fontweight="bold", color="#c0392b")
|
||||
y -= 0.065
|
||||
y -= 0.060
|
||||
|
||||
# ── Middle column: Signal-type attribution ───────────────────────────────
|
||||
ax.text(0.50, 0.83, "Profit Attribution", fontsize=13, fontweight="bold",
|
||||
@@ -660,8 +667,7 @@ def generate_full_report(df, hmm_states, state_names, model_hmm,
|
||||
paths = []
|
||||
paths.append(plot_regime_overlay(df, hmm_states, state_names, tf=tf, broker=broker))
|
||||
paths.append(plot_equity_curve(df, probabilities, hmm_states, split_idx=split_idx,
|
||||
tf=tf, broker=broker,
|
||||
regime_stats=regime_stats))
|
||||
tf=tf, broker=broker))
|
||||
paths.append(plot_feature_analysis(X, hmm_states, metrics, tf=tf, broker=broker))
|
||||
paths.append(plot_transition_matrix(model_hmm, state_names, tf=tf, broker=broker))
|
||||
paths.append(plot_summary_dashboard(result_enriched, params or {},
|
||||
|
||||
Reference in New Issue
Block a user