feat: Enhance CPCV metrics handling and reporting across multiple modules

This commit is contained in:
Luke Mosoti
2026-05-16 22:58:04 +03:00
parent 2af8c2fb0f
commit d656d1b672
5 changed files with 179 additions and 29 deletions
+37 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+1 -1
View File
@@ -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
View File
@@ -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 {},