Files
GoldRegime_X/notebooks/GoldRegimeX_Explorer.ipynb
Luke Mosoti c75e5c7bad Add central pipeline verification module and session filter implementation
- Introduced `pipeline_verification.py` containing multiple verifier classes for dataset integrity, feature leakage, evaluation integrity, prediction alignment, and more.
- Added `SessionFilter` class in `session_filter.py` for unified session/time-of-day filtering.
- Implemented `VerificationResult` data class for structured verification results.
- Created utility functions for hashing and manifest building.
- Established a test suite for the session filter to ensure correct functionality.
- Updated `__init__.py` to include the new shared module.
2026-07-07 16:04:10 +03:00

277 KiB

GoldRegime X - Iteration 2: Fast Sensitivity Plateau Lab (M5/M15)

This notebook replaces Optuna with a robust and faster Grid Sensitivity Plateau workflow:

  1. Feature engineering + Triple-Barrier labeling
  2. CPCV with Purge + Embargo
  3. HMM + XGBoost composite model
  4. Coarse-to-fine parameter search with parallel execution
  5. Plateau-center selection
  6. OOS holdout and cost stress tests

Goal: Find stable parameter regions for M5/M15 that survive out-of-sample and transaction-cost stress.

In [1]:
import os
import sys
import warnings
from pathlib import Path
from dataclasses import dataclass
from typing import Iterator, Tuple
import itertools
import math
import time

# ---- Project root bootstrap (critical for joblib child processes on Windows) ----
_here = Path.cwd().resolve()
if _here.name.lower() == "notebooks":
    _project_root = _here.parent
else:
    _project_root = _here

os.chdir(_project_root)

_project_root_str = str(_project_root)
if _project_root_str not in sys.path:
    sys.path.insert(0, _project_root_str)

_prev_py_path = os.environ.get("PYTHONPATH", "")
if _prev_py_path:
    if _project_root_str not in _prev_py_path.split(os.pathsep):
        os.environ["PYTHONPATH"] = _project_root_str + os.pathsep + _prev_py_path
else:
    os.environ["PYTHONPATH"] = _project_root_str

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Optional ML dependencies. In a full local quant environment these should import normally.
# Fallbacks only exist so the notebook can be smoke-tested in this restricted sandbox.
try:
    from sklearn.base import BaseEstimator, ClassifierMixin
    from sklearn.preprocessing import StandardScaler
    SKLEARN_OK = True
except Exception:
    SKLEARN_OK = False
    class BaseEstimator: pass
    class ClassifierMixin: pass
    class StandardScaler:
        def fit_transform(self, X):
            X = np.asarray(X, dtype=float)
            self.mean_ = np.nanmean(X, axis=0)
            self.std_ = np.nanstd(X, axis=0)
            self.std_[self.std_ == 0] = 1.0
            return (X - self.mean_) / self.std_
        def transform(self, X):
            X = np.asarray(X, dtype=float)
            return (X - self.mean_) / self.std_

try:
    from hmmlearn.hmm import GaussianHMM
    HMM_OK = True
except Exception:
    HMM_OK = False
    class GaussianHMM:
        def __init__(self, n_components=3, covariance_type="full", n_iter=120, random_state=42, **kwargs):
            # kwargs (e.g. min_covar) ignored by the lightweight fallback.
            self.n_components = int(n_components)
            self.random_state = int(random_state)
        def fit(self, X):
            X = np.asarray(X, dtype=float)
            self.n_features_ = X.shape[1] if X.ndim == 2 else 1
            return self
        def predict(self, X):
            X = np.asarray(X, dtype=float)
            if X.ndim == 1:
                X = X.reshape(-1, 1)
            score = np.nan_to_num(X[:, 0], nan=0.0)
            if len(score) >= 3:
                q1, q2 = np.quantile(score, [1/3, 2/3])
            else:
                q1, q2 = 0.0, 0.0
            return np.where(score <= q1, 0, np.where(score <= q2, 1, 2)).astype(int)

try:
    import xgboost as xgb
    XGB_OK = True
except Exception:
    XGB_OK = False
    class _FallbackXGBClassifier:
        def __init__(self, **kwargs):
            self.kwargs = kwargs
        def fit(self, X, y):
            y = np.asarray(y, dtype=int)
            counts = np.bincount(y, minlength=3).astype(float)
            if counts.sum() == 0:
                counts[:] = 1.0
            self.prior_ = counts / counts.sum()
            return self
        def predict_proba(self, X):
            X = np.asarray(X, dtype=float)
            return np.tile(self.prior_, (len(X), 1))
        def predict(self, X):
            return np.argmax(self.predict_proba(X), axis=1).astype(int)
    class _XGBModule:
        XGBClassifier = _FallbackXGBClassifier
    xgb = _XGBModule()

try:
    from numba import njit
    NUMBA_OK = True
except Exception:
    NUMBA_OK = False
    def njit(*args, **kwargs):
        if args and callable(args[0]):
            return args[0]
        def deco(fn):
            return fn
        return deco

try:
    from joblib import Parallel, delayed
    JOBLIB_OK = True
except Exception:
    JOBLIB_OK = False
    class Parallel:
        def __init__(self, n_jobs=1, backend=None, verbose=0):
            self.n_jobs = n_jobs
        def __call__(self, jobs):
            return [job() if callable(job) else job for job in jobs]
    def delayed(fn):
        def wrapper(*args, **kwargs):
            return lambda: fn(*args, **kwargs)
        return wrapper

try:
    from src.backtester import vectorized_backtest
    from src.trade_lifecycle import config_for_tf
    from src.risk_manager import BROKER_CONFIGS
    SRC_OK = True
except Exception:
    SRC_OK = False
    vectorized_backtest = None
    def config_for_tf(*args, **kwargs):
        return {}
    BROKER_CONFIGS = {}

warnings.filterwarnings("ignore")
np.random.seed(42)

print("Working directory:", os.getcwd())
print("Project root:", _project_root_str)
print("PYTHONPATH:", os.environ.get("PYTHONPATH", ""))
print("joblib available:", JOBLIB_OK)
print("optional deps:", {"sklearn": SKLEARN_OK, "hmmlearn": HMM_OK, "xgboost": XGB_OK, "numba": NUMBA_OK, "src": SRC_OK})
Working directory: C:\GoldRegime_X
Project root: C:\GoldRegime_X
PYTHONPATH: C:\GoldRegime_X
joblib available: True
optional deps: {'sklearn': True, 'hmmlearn': True, 'xgboost': True, 'numba': True, 'src': True}
In [2]:
# -----------------------------
# Global config
# -----------------------------

RANDOM_STATE = 42
np.random.seed(RANDOM_STATE)

# Strategy TF contract
EXEC_TF = "M5"
TREND_TF = "M15"

# Account and execution assumptions
INITIAL_BALANCE_CENTS = 1500.0
SPREAD_CAP_POINTS = 40.0          # 40 points max = 4.0 pips
STOP_LOSS_PIPS = 15.0
RR_MULT = 2.0
MAX_POSITIONS_PER_CYCLE = 2
LOT_CYCLE_SMALL = [0.01, 0.02]    # for 15 USD account
BALANCE_SCALE_THRESHOLD_CENTS = 5000.0  # 50 USD

# Runtime controls
N_JOBS = max(1, (os.cpu_count() or 4) - 1)

# CPCV-like controls for strategy parameter search
COARSE_CPCV_N_BLOCKS = 4
COARSE_CPCV_K_VAL = 2
FINE_CPCV_N_BLOCKS = 4
FINE_CPCV_K_VAL = 2
EMBARGO_HOURS = 24

# Holdout split
HOLDOUT_FRAC = 0.20
MAX_DATA_YEARS = 5   # Set to int (e.g. 5 or 10) to use only the last N years of data; None = use all available

BARS_PER_DAY = {"M15": 96, "M5": 288}
BARS_PER_YEAR = {"M15": 252 * 96, "M5": 252 * 288}

# Source paths
TF_TO_XAU_RAW = {
    "M15": Path("data/raw/XAU_15m_data.csv"),
    "M5": Path("data/raw/XAU_5m_data.csv"),
}

# Cross-commodity master close files (tab-separated MetaTrader format)
TF_TO_XAG_MASTER = {
    "M15": Path("data/raw/XAGUSD_M15_201601040100_202605072245.csv"),
    "M5":  Path("data/raw/XAGUSD_M5_201601040105_202605072255.csv"),
}
TF_TO_XTI_MASTER = {
    "M15": Path("data/raw/XTIUSD_M15_201702102000_202605072345.csv"),
    "M5":  Path("data/raw/XTIUSD_M5_201702102000_202605072355.csv"),
}

# -----------------------------
# Timeframes (Phase 4 / Phase 6)
# -----------------------------
TIMEFRAMES = ["M15", "M5"]

# -----------------------------
# HMM Feature Registry (Phase 3)
# Explicit, version-controlled feature list for the stationary HMM.
# Avoids silently consuming all numeric columns.
# -----------------------------
HMM_FEATURES = {
    "volatility": [
        "atr_20", "atr_normalized", "volatility_20",
        "synth_vix_zscore", "atr14", "atr100", "atr_expansion",
    ],
    "cross_commodity": [
        "log_return", "xag_log_return", "xti_log_return",
        "gold_silver_ratio_z", "gold_oil_ratio_z",
    ],
    "oscillators": [
        "rsi5",
    ],
    "temporal": [
        "hour_sin", "hour_cos",
    ],
    "regime": [
        "regime_code",
    ],
}


def get_hmm_feature_list():
    "Flatten the HMM feature registry into a single list."
    features = []
    for group in HMM_FEATURES.values():
        features.extend(group)
    return features


# -----------------------------
# Centralized Train/OOS Split (Phase 5)
# Single consistent split mechanism used by all stages.
# -----------------------------
def split_dataset(df, holdout_frac):
    "Chronological train/OOS split. Returns (train_df, oos_df, split_time)."
    df = df.sort_index().copy()
    if len(df) < 1000:
        raise RuntimeError("Not enough rows for split. Got %d rows." % len(df))
    split_idx = int(len(df) * (1.0 - holdout_frac))
    split_idx = max(2000, min(split_idx, len(df) - 1))
    train = df.iloc[:split_idx].copy()
    oos = df.iloc[split_idx:].copy()
    split_time = train.index[-1]
    return train, oos, split_time


# -----------------------------
# Pipeline Container (Phase 6-7 / Phase 11)
# Each timeframe owns its own state. No shared mutable state.
# -----------------------------
class TimeframePipeline:
    "Owns all state for a single timeframe ML experiment."
    def __init__(self, timeframe):
        self.timeframe = timeframe
        self.raw_all = None           # Raw panel data
        self.train_df = None          # IS training data
        self.oos_df = None            # OOS holdout data
        self.split_time = None        # Split timestamp
        self.train_feat = None        # Engineered features (train)
        self.model = None             # Trained HMMXGBComposite
        self.threshold = None         # Optimised xgb_threshold
        self.plateau = None           # Plateau centre dict
        self.metrics_is = None        # IS validation metrics
        self.metrics_oos = None       # OOS validation metrics
        self.trades_is = None         # IS trades dataframe
        self.trades_oos = None        # OOS trades dataframe


# Initialise one pipeline per timeframe
pipeline = {tf: TimeframePipeline(tf) for tf in TIMEFRAMES}

print("Execution TF:", EXEC_TF)
print("Trend TF:", TREND_TF)
print("Initial balance (cents):", INITIAL_BALANCE_CENTS)
print("Spread cap (points):", SPREAD_CAP_POINTS)
print("CPCV paths coarse:", math.comb(COARSE_CPCV_N_BLOCKS, COARSE_CPCV_K_VAL))
print("CPCV paths fine:", math.comb(FINE_CPCV_N_BLOCKS, FINE_CPCV_K_VAL))
print("Max data years:", MAX_DATA_YEARS if MAX_DATA_YEARS else "All available")
Execution TF: M5
Trend TF: M15
Initial balance (cents): 1500.0
Spread cap (points): 40.0
CPCV paths coarse: 6
CPCV paths fine: 6
Max data years: 5
In [3]:
# ---------------------------------------------------------
# INGESTION LAYER: Import validated parameters from Strategy Tester
# ---------------------------------------------------------
import json

def load_optimized_strategies(filepath="reports/strategy_winners_for_explorer.csv", max_dd=25.0):
    try:
        winners_df = pd.read_csv(filepath)
        print(f"Loaded {len(winners_df)} candidate strategies from bridge.")
    except FileNotFoundError:
        raise FileNotFoundError(f"Bridge file {filepath} not found. Run Strategy_Tester.ipynb first.")

    strict_winners = winners_df[winners_df["max_drawdown"] <= max_dd].copy()
    best_params_by_tf = {}

    for tf in ["M15", "M5"]:
        tf_df = strict_winners[strict_winners["timeframe"] == tf]
        if tf_df.empty:
            print(f"WARNING: No strategies for {tf} survived the {max_dd}% DD filter.")
            best_params_by_tf[tf] = None
            continue

        top_row = tf_df.sort_values("robust_score", ascending=False).iloc[0]
        params_dict = json.loads(top_row["parameter_set"])

        best_params_by_tf[tf] = {
            "strategy_name": top_row["strategy_name"],
            "exit_model": top_row["exit_model"],
            "parameters": params_dict,
            "expected_pf": top_row["profit_factor"],
            "expected_dd": top_row["max_drawdown"],
        }
        print(f"Acquired {tf} Baseline -> PF: {top_row['profit_factor']:.2f} | DD: {top_row['max_drawdown']:.2f}%")

    return best_params_by_tf

ML_TARGET_PARAMS = load_optimized_strategies(max_dd=25.0)

if ML_TARGET_PARAMS.get(EXEC_TF) is None:
    raise RuntimeError(f"No {EXEC_TF} baseline survived 25% DD filter. Re-run Strategy Tester.")
Loaded 2 candidate strategies from bridge.
Acquired M15 Baseline -> PF: 1.23 | DD: 24.00%
Acquired M5 Baseline -> PF: 1.18 | DD: 24.00%
In [4]:
# -----------------------------
# Data loading helpers
# -----------------------------

def _normalize_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
    cols = {c.lower(): c for c in df.columns}
    rename_map = {}
    if "open" in cols:
        rename_map[cols["open"]] = "Open"
    if "high" in cols:
        rename_map[cols["high"]] = "High"
    if "low" in cols:
        rename_map[cols["low"]] = "Low"
    if "close" in cols:
        rename_map[cols["close"]] = "Close"
    if "volume" in cols:
        rename_map[cols["volume"]] = "Volume"

    out = df.rename(columns=rename_map)
    need = ["Open", "High", "Low", "Close"]
    missing = [c for c in need if c not in out.columns]
    if missing:
        raise ValueError(f"Missing OHLC columns: {missing}")
    return out

def read_xau_raw(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(path)

    df = pd.read_csv(path, sep=";")
    if "Date" not in df.columns:
        raise ValueError(f"Date column missing in {path}")

    df["Date"] = pd.to_datetime(df["Date"], format="%Y.%m.%d %H:%M", errors="coerce")
    df = df.dropna(subset=["Date"]).set_index("Date").sort_index()
    df = _normalize_ohlcv(df)
    return df

def read_mt4_csv(path: Path) -> pd.DataFrame:
    """Read MetaTrader-exported CSV (tab-separated, <UPPERCASE> headers, date+time cols)."""
    if not path.exists():
        raise FileNotFoundError(path)
    df = pd.read_csv(path, sep="	")
    # Strip angle brackets from column names: <DATE> → DATE, <CLOSE> → CLOSE etc.
    df.columns = [c.strip("<>") for c in df.columns]
    # Combine DATE + TIME into a datetime index
    df["DateTime"] = pd.to_datetime(
        df["DATE"].astype(str) + " " + df["TIME"].astype(str),
        format="%Y.%m.%d %H:%M:%S",
        errors="coerce",
    )
    df = df.dropna(subset=["DateTime"]).set_index("DateTime").sort_index()
    # Normalise to title-case for _normalize_ohlcv compatibility
    rename_map = {}
    for tag in ["OPEN", "HIGH", "LOW", "CLOSE", "TICKVOL", "VOL", "SPREAD"]:
        if tag in df.columns:
            rename_map[tag] = tag.capitalize()
    # Special: TICKVOL → Volume (since MT4 TICKVOL is the useful volume field)
    if "Tickvol" in df.columns:
        rename_map["Tickvol"] = "Volume"
    df = df.rename(columns=rename_map)
    return df

def read_master_close(path: Path) -> pd.Series:
    """Read commodity series — auto-detects XAU semicolon format vs MT4 tab format."""
    if not path.exists():
        raise FileNotFoundError(path)

    # Peek at first line to detect format
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        header = fh.readline()

    if "	" in header or header.strip().startswith("<"):
        # MT4 tab-separated format (XAG/XTI)
        df = read_mt4_csv(path)
        col_name = "Close" if "Close" in df.columns else "close"
        return df[col_name].astype(float).sort_index()
    else:
        # XAU semicolon-separated format
        df = pd.read_csv(path, sep=";", index_col=0, parse_dates=True).sort_index()
        if "Close" in df.columns:
            s = df["Close"].astype(float)
        elif "close" in df.columns:
            s = df["close"].astype(float)
        else:
            raise ValueError(f"Close column missing in {path}")
        return s

def load_panel(tf: str) -> pd.DataFrame:
    tf = tf.upper()
    xau = read_xau_raw(TF_TO_XAU_RAW[tf])

    out = xau.copy()

    # Cross-commodity series (uses TF_TO_XAG_MASTER / TF_TO_XTI_MASTER from config).
    xag_map = TF_TO_XAG_MASTER
    xti_map = TF_TO_XTI_MASTER

    if isinstance(xag_map, dict) and tf in xag_map and Path(xag_map[tf]).exists():
        xag = read_master_close(Path(xag_map[tf])).rename("XAG_Close")
        out["XAG_Close"] = xag.reindex(out.index).ffill()
    else:
        # Fallback keeps old feature functions from breaking.
        out["XAG_Close"] = out["Close"].astype(float)

    if isinstance(xti_map, dict) and tf in xti_map and Path(xti_map[tf]).exists():
        xti = read_master_close(Path(xti_map[tf])).rename("XTI_Close")
        out["XTI_Close"] = xti.reindex(out.index).ffill()
    else:
        out["XTI_Close"] = out["Close"].astype(float)

    # Drop rows where cross-commodity data is NaN
    # (but only if we actually loaded real external data — skip if fallback was used
    #  to avoid dropping the entire early history where XAG/XTI don't exist yet)
    has_real_xag = isinstance(xag_map, dict) and tf in xag_map and Path(xag_map[tf]).exists()
    has_real_xti = isinstance(xti_map, dict) and tf in xti_map and Path(xti_map[tf]).exists()
    if has_real_xag or has_real_xti:
        # Only drop if BOTH external series are NaN — keep rows where at least one exists
        # Forward-fill already applied above; remaining NaN means no data at all for that period
        drop_mask = out["XAG_Close"].isna() & out["XTI_Close"].isna()
        if drop_mask.any():
            print(f"  [{tf}] Dropping {drop_mask.sum()} rows with no XAG or XTI data (before their start dates)")
        out = out[~drop_mask].copy()
        # Fill any remaining single-commodity NaNs with XAU Close as fallback
        out["XAG_Close"] = out["XAG_Close"].fillna(out["Close"].astype(float))
        out["XTI_Close"] = out["XTI_Close"].fillna(out["Close"].astype(float))

    # --- MAX_DATA_YEARS filter: truncate to last N years ---
    max_years = MAX_DATA_YEARS
    if max_years is not None and isinstance(max_years, (int, float)) and max_years > 0:
        cutoff = out.index.max() - pd.DateOffset(years=int(max_years))
        before_count = len(out)
        out = out.loc[out.index >= cutoff].copy()
        print(f"  [{tf}] MAX_DATA_YEARS={max_years}: {before_count} → {len(out)} rows (cutoff {cutoff:%Y-%m-%d})")

    return out
In [5]:
# ---------------------------------------------------------
# Feature engineering + labels
# ---------------------------------------------------------
import pandas as pd
import numpy as np
from numba import njit

def ema(series: pd.Series, period: int) -> pd.Series:
    return series.ewm(span=int(period), adjust=False, min_periods=int(period)).mean()

def true_range(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
    prev_close = close.shift(1)
    return pd.concat([high - low, (high - prev_close).abs(), (low - prev_close).abs()], axis=1).max(axis=1)

def atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series:
    tr = true_range(high, low, close)
    return tr.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean()

def rsi(close: pd.Series, period: int = 14) -> pd.Series:
    delta = close.diff()
    gain = delta.clip(lower=0.0)
    loss = -delta.clip(upper=0.0)
    avg_gain = gain.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean()
    avg_loss = loss.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean()
    rs = avg_gain / avg_loss.replace(0.0, np.nan)
    return (100.0 - (100.0 / (1.0 + rs))).fillna(50.0)

def adx(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series:
    up_move = high.diff()
    down_move = -low.diff()
    plus_dm = up_move.where((up_move > down_move) & (up_move > 0.0), 0.0)
    minus_dm = down_move.where((down_move > up_move) & (down_move > 0.0), 0.0)
    tr = true_range(high, low, close)
    atr_v = tr.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean().replace(0.0, np.nan)
    plus_di = 100.0 * plus_dm.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean() / atr_v
    minus_di = 100.0 * minus_dm.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean() / atr_v
    dx = 100.0 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0.0, np.nan)
    return dx.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean().fillna(0.0)

def synth_vix_zscore(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 22) -> pd.Series:
    tr = true_range(high, low, close)
    roll_mean = tr.rolling(period, min_periods=period).mean()
    roll_std = tr.rolling(period, min_periods=period).std().replace(0, np.nan)
    return ((tr - roll_mean) / roll_std).fillna(0.0)

def add_session_features(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    hour = out.index.hour
    london = (hour >= 7) & (hour < 16)
    ny = (hour >= 13) & (hour < 21)
    overlap = (hour >= 13) & (hour < 16)
    out["session"] = np.where(overlap, "OVERLAP", np.where(london, "LONDON", np.where(ny, "NEW_YORK", "ASIA")))
    out["session_mask_none"] = True
    out["session_mask_london"] = london
    out["session_mask_ny"] = ny
    out["session_mask_london_ny"] = london | ny
    return out

def build_features(exec_panel: pd.DataFrame, exec_tf: str) -> pd.DataFrame:
    exec_tf = exec_tf.upper()
    if exec_tf not in ("M5", "M15"):
        raise ValueError(f"Unsupported timeframe: {exec_tf}")

    exec_df = exec_panel.copy()

    exec_df["log_return"] = np.log(exec_df["Close"] / exec_df["Close"].shift(1))
    exec_df["xag_log_return"] = np.log(exec_df["XAG_Close"] / exec_df["XAG_Close"].shift(1))
    exec_df["xti_log_return"] = np.log(exec_df["XTI_Close"] / exec_df["XTI_Close"].shift(1))

    exec_df["gold_silver_ratio"] = exec_df["Close"] / exec_df["XAG_Close"]
    exec_df["gold_oil_ratio"] = exec_df["Close"] / exec_df["XTI_Close"]

    rw = 64
    gs_m = exec_df["gold_silver_ratio"].rolling(rw, min_periods=rw).mean()
    gs_s = exec_df["gold_silver_ratio"].rolling(rw, min_periods=rw).std().replace(0, np.nan)
    go_m = exec_df["gold_oil_ratio"].rolling(rw, min_periods=rw).mean()
    go_s = exec_df["gold_oil_ratio"].rolling(rw, min_periods=rw).std().replace(0, np.nan)

    exec_df["gold_silver_ratio_z"] = ((exec_df["gold_silver_ratio"] - gs_m) / gs_s).replace([np.inf, -np.inf], np.nan).fillna(0.0)
    exec_df["gold_oil_ratio_z"] = ((exec_df["gold_oil_ratio"] - go_m) / go_s).replace([np.inf, -np.inf], np.nan).fillna(0.0)

    tr = true_range(exec_df["High"], exec_df["Low"], exec_df["Close"])
    exec_df["atr_20"] = tr.rolling(20, min_periods=20).mean()
    exec_df["atr_normalized"] = exec_df["atr_20"] / exec_df["Close"]
    exec_df["volatility_20"] = exec_df["log_return"].rolling(20, min_periods=20).std()
    exec_df["synth_vix_zscore"] = synth_vix_zscore(exec_df["High"], exec_df["Low"], exec_df["Close"], period=22)

    exec_df["hour"] = exec_df.index.hour
    exec_df["hour_sin"] = np.sin(2 * np.pi * exec_df["hour"] / 24.0)
    exec_df["hour_cos"] = np.cos(2 * np.pi * exec_df["hour"] / 24.0)

    exec_df["rsi5"] = rsi(exec_df["Close"], period=5)
    exec_df["atr14"] = atr(exec_df["High"], exec_df["Low"], exec_df["Close"], period=14)
    exec_df["atr100"] = atr(exec_df["High"], exec_df["Low"], exec_df["Close"], period=100)
    exec_df["atr_expansion"] = exec_df["atr14"] / exec_df["atr100"].replace(0.0, np.nan)

    exec_df["macro_ema50"] = ema(exec_df["Close"], period=50)
    exec_df["macro_ema200"] = ema(exec_df["Close"], period=200)
    exec_df["macro_adx14"] = adx(exec_df["High"], exec_df["Low"], exec_df["Close"], period=14)

    merged = add_session_features(exec_df)
    merged["spread"] = SPREAD_CAP_POINTS

    is_trend = (merged["macro_adx14"] > 15.0) & (merged["atr_expansion"] < 1.3)
    is_shock = merged["atr_expansion"] >= 1.3
    merged["regime_str"] = np.where(is_shock, "SHOCK", np.where(is_trend, "TREND", "MR"))
    merged["regime_code"] = merged["regime_str"].map({"TREND": 1, "SHOCK": 2, "MR": 3})

    keep = [
        "Open", "High", "Low", "Close",
        "log_return", "xag_log_return", "xti_log_return",
        "gold_silver_ratio_z", "gold_oil_ratio_z",
        "atr_20", "atr_normalized", "volatility_20",
        "synth_vix_zscore", "hour_sin", "hour_cos",
        "rsi5", "atr14", "atr100", "atr_expansion",
        "macro_ema50", "macro_ema200", "macro_adx14",
        "spread", "regime_str", "regime_code",
        "session_mask_none", "session_mask_london", "session_mask_ny", "session_mask_london_ny",
    ]
    merged = merged[keep].dropna().copy()
    return triple_barrier(merged, exec_tf)

@njit(cache=True)
def _triple_barrier_numba(close, high, low, atr_v, horizon, atr_mult):
    n = len(close)
    label = np.zeros(n, dtype=np.int8)
    event_end_pos = np.arange(n, dtype=np.int32)

    for i in range(n):
        if i + 1 >= n or np.isnan(atr_v[i]):
            label[i] = 0
            event_end_pos[i] = i
            continue

        up = close[i] + atr_mult * atr_v[i]
        dn = close[i] - atr_mult * atr_v[i]
        end_i = min(n - 1, i + horizon)

        hit = 0
        hit_pos = end_i
        for j in range(i + 1, end_i + 1):
            up_hit = high[j] >= up
            dn_hit = low[j] <= dn

            if up_hit and dn_hit:
                hit = 0
                hit_pos = j
                break
            if up_hit:
                hit = 1
                hit_pos = j
                break
            if dn_hit:
                hit = -1
                hit_pos = j
                break

        label[i] = hit
        event_end_pos[i] = hit_pos
        
    return label, event_end_pos

def triple_barrier(df: pd.DataFrame, tf: str, atr_mult: float = 1.5) -> pd.DataFrame:
    out = df.copy()
    horizon = 12 if tf.upper() == "M5" else 4

    close = out["Close"].to_numpy()
    high = out["High"].to_numpy()
    low = out["Low"].to_numpy()
    atr_v = out["atr_20"].to_numpy()

    # Call the insanely fast Numba compiler here
    label, event_end_pos = _triple_barrier_numba(close, high, low, atr_v, horizon, atr_mult)

    out["tb_label"] = label
    out["event_end_pos"] = event_end_pos
    out["event_end_time"] = out.index[event_end_pos]
    return out

LABEL_COLS = {"tb_label", "event_end_pos", "event_end_time"}

def hmm_feature_columns(df: pd.DataFrame) -> list[str]:
    # Phase 3: Explicit HMM feature registry (not dynamic column discovery)
    # Falls back to stationary numeric columns only if registry yields nothing.
    registry_features = get_hmm_feature_list()
    available = [c for c in registry_features if c in df.columns]
    if not available:
        non_stationary = {"Open", "High", "Low", "Close", "macro_ema50", "macro_ema200"}
        numeric_df = df.select_dtypes(include=['number', 'bool'])
        available = [c for c in numeric_df.columns if c not in LABEL_COLS and c not in non_stationary]
        print("WARNING: HMM feature registry empty, fell back to %d dynamic columns" % len(available))
    return available
In [6]:
# -----------------------------
# CPCV splitter with Purge + Embargo
# -----------------------------

@dataclass
class CPCVPurgedEmbargo:
    n_blocks: int = 6
    k_val_blocks: int = 2
    embargo_bars: int = 96

    def split(self, n: int, event_end_pos: np.ndarray) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
        all_idx = np.arange(n, dtype=np.int32)
        blocks = [np.array(x, dtype=np.int32) for x in np.array_split(all_idx, self.n_blocks)]
        block_ranges = [(b[0], b[-1]) for b in blocks if len(b) > 0]

        block_ids = list(range(len(blocks)))
        for combo in itertools.combinations(block_ids, self.k_val_blocks):
            val_blocks = [blocks[c] for c in combo]
            val_idx = np.sort(np.concatenate(val_blocks))

            val_ranges = [block_ranges[c] for c in combo]
            train_mask = np.ones(n, dtype=bool)
            train_mask[val_idx] = False
            train_idx = np.where(train_mask)[0]

            # Purge overlap with label windows
            keep = np.ones(len(train_idx), dtype=bool)
            for k, i in enumerate(train_idx):
                e_i = int(event_end_pos[i])
                for v_start, v_end in val_ranges:
                    if (i <= v_end) and (e_i >= v_start):
                        keep[k] = False
                        break
            train_idx = train_idx[keep]

            # Embargo after each validation range
            embargo_mask = np.zeros(n, dtype=bool)
            for _, v_end in val_ranges:
                e_s = v_end + 1
                e_e = min(n - 1, v_end + self.embargo_bars)
                if e_s <= e_e:
                    embargo_mask[e_s:e_e + 1] = True
            train_idx = train_idx[~embargo_mask[train_idx]]

            if len(train_idx) == 0 or len(val_idx) == 0:
                continue

            yield np.sort(train_idx), val_idx
In [7]:
# -----------------------------
# HMM + XGBoost composite
# -----------------------------

class HMMXGBComposite(BaseEstimator, ClassifierMixin):
    def __init__(
        self,
        n_components=3,
        max_depth=4,
        learning_rate=0.05,
        n_estimators=200,
        random_state=42,
    ):
        self.n_components = int(n_components)
        self.max_depth = int(max_depth)
        self.learning_rate = float(learning_rate)
        self.n_estimators = int(n_estimators)
        self.random_state = int(random_state)

        self.hmm = None
        self.hmm_scaler = None
        self.xgb = None
        self.feature_names_ = None
        self.hmm_features_ = None

        self.y_to_cls_ = {-1: 0, 0: 1, 1: 2}
        self.cls_to_y_ = {0: -1, 1: 0, 2: 1}

    def fit(self, X: pd.DataFrame, y: pd.Series, hmm_features: list[str]):
        X = X.copy()
        y = pd.Series(y).astype(int)

        # FIX: Strictly isolate numeric columns to prevent strings like 'TREND' from crashing XGBoost
        X_numeric = X.select_dtypes(include=['number', 'bool'])
        self.feature_names_ = list(X_numeric.columns)
        self.hmm_features_ = list(hmm_features)

        X_hmm = X[self.hmm_features_].to_numpy(dtype=float)
        self.hmm_scaler = StandardScaler()
        X_hmm = self.hmm_scaler.fit_transform(X_hmm)

        # Robust HMM fit with convergence-aware fallback
        # Strategy: try multiple covariance configs; check monitor_.converged
        # after fit.  If a config does not converge, skip to the next one.
        # If none converge, use the one with the highest final log-likelihood.
        import logging

        # Temporarily suppress hmmlearn's "Model is not converging" logger noise
        _hmm_logger = logging.getLogger("hmmlearn")
        _prev_level = _hmm_logger.level
        _hmm_logger.setLevel(logging.CRITICAL + 1)

        covar_configs = [
            {"covariance_type": "diag", "min_covar": 0.01},
            {"covariance_type": "diag", "min_covar": 0.1},
            {"covariance_type": "full", "min_covar": 0.01},
            {"covariance_type": "full", "min_covar": 0.1},
            {"covariance_type": "spherical", "min_covar": 0.01},
        ]
        self.hmm = None
        last_err = None
        best_hmm = None
        best_ll = -np.inf

        for _cfg in covar_configs:
            try:
                _hmm = GaussianHMM(
                    n_components=self.n_components,
                    n_iter=300,
                    tol=1e-3,
                    random_state=self.random_state,
                    **_cfg,
                )
                _hmm.fit(X_hmm)
                _did_converge = bool(_hmm.monitor_.converged)
                _regimes = _hmm.predict(X_hmm)
                _ll = _hmm.score(X_hmm)

                if _did_converge:
                    self.hmm = _hmm
                    regimes = _regimes
                    last_err = None
                    _ct = _cfg["covariance_type"]
                    _mc = _cfg["min_covar"]
                    print(f"  HMM converged ({_ct}/{_mc}) LL={_ll:.1f}")
                    break
                else:
                    # Non-converged but usable - keep as fallback if LL is best
                    if _ll > best_ll:
                        best_ll = _ll
                        best_hmm = (_hmm, _regimes, _cfg)
                    continue

            except Exception as _e:
                last_err = _e
                continue

        # Restore hmmlearn logger level
        _hmm_logger.setLevel(_prev_level)

        # If no config fully converged, use the best non-converged one
        if self.hmm is None and best_hmm is not None:
            self.hmm = best_hmm[0]
            regimes = best_hmm[1]
            _cfg = best_hmm[2]
            _ct = _cfg["covariance_type"]
            _mc = _cfg["min_covar"]
            print(f"  HMM: no config fully converged; best LL ({_ct}/{_mc}) LL={best_ll:.1f}")

        if self.hmm is None:
            raise RuntimeError(
                f"HMM fit failed for all covariance configs: {last_err}"
            ) from last_err
        reg_oh = np.eye(self.n_components, dtype=float)[regimes]

        # FIX: Use the safely isolated X_numeric array instead of the raw X dataframe
        X_xgb = np.hstack([X_numeric.to_numpy(dtype=float), reg_oh])
        y_cls = y.map(self.y_to_cls_).to_numpy(dtype=int)

        self.xgb = xgb.XGBClassifier(
            objective="multi:softprob",
            num_class=3,
            max_depth=self.max_depth,
            learning_rate=self.learning_rate,
            n_estimators=self.n_estimators,
            subsample=0.9,
            colsample_bytree=0.9,
            reg_alpha=0.0,
            reg_lambda=1.0,
            random_state=self.random_state,
            eval_metric="mlogloss",
            tree_method="hist",
            n_jobs=1,
        )
        self.xgb.fit(X_xgb, y_cls)
        return self

    def _augment(self, X: pd.DataFrame) -> np.ndarray:
        # Use self.feature_names_ which now safely contains ONLY numeric features
        X_num = X[self.feature_names_].to_numpy(dtype=float)
        X_hmm = X[self.hmm_features_].to_numpy(dtype=float)
        X_hmm = self.hmm_scaler.transform(X_hmm)
        regimes = self.hmm.predict(X_hmm)
        reg_oh = np.eye(self.n_components, dtype=float)[regimes]
        return np.hstack([X_num, reg_oh])

    def predict(self, X: pd.DataFrame) -> np.ndarray:
        X_aug = self._augment(X)
        cls = self.xgb.predict(X_aug).astype(int)
        y = np.array([self.cls_to_y_[int(c)] for c in cls], dtype=np.int8)
        return y

    def predict_proba_raw(self, X: pd.DataFrame) -> np.ndarray:
        X_aug = self._augment(X)
        return self.xgb.predict_proba(X_aug)
In [8]:
# -----------------------------
# Numba Engine + ML Trend Pullback Wrapper
# -----------------------------
try:
    from numba import njit
except Exception:
    def njit(*args, **kwargs):
        if args and callable(args[0]):
            return args[0]
        def deco(fn):
            return fn
        return deco

# Position split constants (required by numba engine body)
POSITION_A = 0.01
POSITION_B = 0.02
LEG_C_ATR_STOP = 0.5
LEG_C_ATR_TARGET = 0.5

PIP_SIZE_PRICE = 0.10
PIP_VALUE_CENTS_PER_1LOT = 100.0
SLIPPAGE_PIPS = 0.30
COMMISSION_CENTS_PER_TRADE = 0.0

@njit(cache=True)
def _run_backtest_numba(
    sig, high, low, close, spread, atr14, regime_code, time_minutes, 
    entry_atr_stop, entry_atr_target, leg_a_atr_target, exit_mode_code, 
    time_stop_minutes, trail_mult, initial_balance, pip_size_price, 
    pip_value_per_1lot, slippage_pips, spread_cap_points, commission_cents, is_m5
):
    n = sig.shape[0]
    max_trades = n * 3

    trade_entry_idx = np.empty(max_trades, dtype=np.int64)
    trade_exit_idx = np.empty(max_trades, dtype=np.int64)
    trade_leg = np.empty(max_trades, dtype=np.int8)
    trade_side = np.empty(max_trades, dtype=np.int8)
    trade_entry_px = np.empty(max_trades, dtype=np.float64)
    trade_exit_px = np.empty(max_trades, dtype=np.float64)
    trade_move_pips = np.empty(max_trades, dtype=np.float64)
    trade_pnl_cents = np.empty(max_trades, dtype=np.float64)
    trade_reason = np.empty(max_trades, dtype=np.int8)
    trade_entry_regime = np.empty(max_trades, dtype=np.int32)

    eq = np.empty(max_trades + 1, dtype=np.float64)
    eq[0] = initial_balance
    eq_count = 1

    legs = np.zeros((3, 7), dtype=np.float64)
    trade_count = 0
    balance = initial_balance
    active_trade_regime = 0
    leg_a_profit_hit = 0
    leg_b_profit_hit = 0

    enable_fixed_tp = exit_mode_code in (0, 2)
    enable_mr = exit_mode_code in (1, 2, 3, 4)
    enable_time_stop = exit_mode_code == 4
    enable_trail = exit_mode_code == 4

    for i in range(n):
        # 1. STRICT INSOLVENCY CIRCUIT BREAKER
        # If account drops below 1000 cents ($10.00), the broker issues a margin call. Dead.
        if balance <= 1000.0:
            break

        s = int(sig[i])
        current_regime = int(regime_code[i])

        is_flat = (legs[0, 0] == 0.0 and legs[1, 0] == 0.0 and legs[2, 0] == 0.0)
        leg_a_closed_b_open = (legs[0, 0] == 0.0 and legs[1, 0] == 1.0 and leg_a_profit_hit == 1)
        leg_b_closed_a_open = (legs[1, 0] == 0.0 and legs[0, 0] == 1.0 and leg_b_profit_hit == 1)
        can_scale_in = (legs[2, 0] == 0.0 and (leg_a_closed_b_open or leg_b_closed_a_open))

        # ENTRY & SCALE-IN LOGIC
        if (is_flat or can_scale_in) and s != 0:
            sp_points = spread[i]
            atr_now = atr14[i]
            
            effective_spread = (sp_points / 10.0) * pip_size_price
            if np.isfinite(atr_now) and atr_now > 0.0 and effective_spread <= (0.8 * atr_now):
                side = 1 if s > 0 else -1

                if can_scale_in:
                    runner_idx = 1 if leg_a_closed_b_open else 0
                    if side != int(legs[runner_idx, 2]):
                        continue 

                spread_price = effective_spread
                slippage_price = slippage_pips * pip_size_price
                close_px = close[i]

                # RISK MANAGEMENT: Dynamic Asymmetric Guard Factor
                if is_m5 == 1:
                    guard_factor = 0.85 if side == 1 else 0.75
                else:
                    guard_factor = 0.65

                if is_flat:
                    leg_a_profit_hit = 0
                    leg_b_profit_hit = 0
                    
                    intended_stop_dist = entry_atr_stop * atr_now
                    actual_stop_dist = intended_stop_dist * guard_factor
                    tp_dist = entry_atr_target * atr_now * guard_factor

                    if side == 1:
                        entry_px = close_px + spread_price + slippage_price
                        stop_px = entry_px - actual_stop_dist
                        fixed_tp_px = entry_px + tp_dist
                        leg_a_tp_px = entry_px + (leg_a_atr_target * atr_now) if leg_a_atr_target > 0.0 else np.nan
                    else:
                        entry_px = close_px - slippage_price
                        stop_px = entry_px + actual_stop_dist + spread_price
                        fixed_tp_px = entry_px - tp_dist
                        leg_a_tp_px = entry_px - (leg_a_atr_target * atr_now) if leg_a_atr_target > 0.0 else np.nan

                    active_trade_regime = current_regime
                    legs[0, 0], legs[0, 1], legs[0, 2], legs[0, 3], legs[0, 4], legs[0, 5], legs[0, 6] = 1.0, POSITION_A, side, entry_px, stop_px, leg_a_tp_px, i
                    legs[1, 0], legs[1, 1], legs[1, 2], legs[1, 3], legs[1, 4], legs[1, 5], legs[1, 6] = 1.0, POSITION_B, side, entry_px, stop_px, fixed_tp_px if enable_fixed_tp else np.nan, i

                elif can_scale_in:
                    leg_c_lot = POSITION_A if leg_a_closed_b_open else POSITION_B
                    
                    tighter_stop = 0.5 * atr_now
                    actual_stop_dist = tighter_stop * guard_factor
                    tighter_tp = 0.5 * atr_now * guard_factor

                    if side == 1:
                        entry_px = close_px + spread_price + slippage_price
                        stop_px = entry_px - actual_stop_dist
                        fixed_tp_px = entry_px + tighter_tp
                    else:
                        entry_px = close_px - slippage_price
                        stop_px = entry_px + actual_stop_dist + spread_price
                        fixed_tp_px = entry_px - tighter_tp

                    legs[2, 0], legs[2, 1], legs[2, 2], legs[2, 3], legs[2, 4], legs[2, 5], legs[2, 6] = 1.0, leg_c_lot, side, entry_px, stop_px, fixed_tp_px, i

        bar_high, bar_low, bar_close, atr_now = high[i], low[i], close[i], atr14[i]

        # EXIT PROCESSING
        for leg_idx in range(3):
            if legs[leg_idx, 0] == 0.0:
                continue

            leg_side = int(legs[leg_idx, 2])
            leg_entry = legs[leg_idx, 3]
            leg_stop = legs[leg_idx, 4]
            leg_tp = legs[leg_idx, 5]
            leg_lot = legs[leg_idx, 1]
            leg_entry_i = int(legs[leg_idx, 6])

            if enable_trail and leg_idx == 1 and np.isfinite(atr_now) and atr_now > 0.0:
                dist = trail_mult * atr_now
                if leg_side == 1:
                    if bar_close - dist > leg_stop:
                        leg_stop = bar_close - dist
                else:
                    if bar_close + dist < leg_stop:
                        leg_stop = bar_close + dist
                legs[leg_idx, 4] = leg_stop

            # 1. Stop Loss Hit
            stop_hit = (bar_low <= leg_stop) if leg_side == 1 else (bar_high >= leg_stop)
            if stop_hit:
                exit_px = leg_stop
                move_pips = ((exit_px - leg_entry) / pip_size_price) * leg_side
                pnl_cents = move_pips * (leg_lot * pip_value_per_1lot) - commission_cents
                trade_entry_idx[trade_count] = leg_entry_i
                trade_exit_idx[trade_count] = i
                trade_leg[trade_count] = leg_idx
                trade_side[trade_count] = leg_side
                trade_entry_px[trade_count] = leg_entry
                trade_exit_px[trade_count] = exit_px
                trade_move_pips[trade_count] = move_pips
                trade_pnl_cents[trade_count] = pnl_cents
                trade_reason[trade_count] = 1
                trade_entry_regime[trade_count] = active_trade_regime
                trade_count += 1
                balance += pnl_cents
                eq[eq_count] = balance
                eq_count += 1
                legs[leg_idx, 0] = 0.0
                if leg_idx == 1: leg_a_profit_hit = 0
                continue

            # 2. Leg 0 (A) Profit Target
            if leg_idx == 0 and np.isfinite(leg_tp):
                tp_hit = (bar_high >= leg_tp) if leg_side == 1 else (bar_low <= leg_tp)
                if tp_hit:
                    exit_px = leg_tp
                    move_pips = ((exit_px - leg_entry) / pip_size_price) * leg_side
                    pnl_cents = move_pips * (leg_lot * pip_value_per_1lot) - commission_cents
                    trade_entry_idx[trade_count] = leg_entry_i
                    trade_exit_idx[trade_count] = i
                    trade_leg[trade_count] = leg_idx
                    trade_side[trade_count] = leg_side
                    trade_entry_px[trade_count] = leg_entry
                    trade_exit_px[trade_count] = exit_px
                    trade_move_pips[trade_count] = move_pips
                    trade_pnl_cents[trade_count] = pnl_cents
                    trade_reason[trade_count] = 2
                    trade_entry_regime[trade_count] = active_trade_regime
                    trade_count += 1
                    balance += pnl_cents
                    eq[eq_count] = balance
                    eq_count += 1
                    legs[leg_idx, 0] = 0.0
                    leg_a_profit_hit = 1
                    continue

            # 3. Leg B or Leg C Fixed TP
            if leg_idx in (1, 2) and enable_fixed_tp and np.isfinite(leg_tp):
                tp_hit = (bar_high >= leg_tp) if leg_side == 1 else (bar_low <= leg_tp)
                if tp_hit:
                    exit_px = leg_tp
                    move_pips = ((exit_px - leg_entry) / pip_size_price) * leg_side
                    pnl_cents = move_pips * (leg_lot * pip_value_per_1lot) - commission_cents
                    trade_entry_idx[trade_count] = leg_entry_i
                    trade_exit_idx[trade_count] = i
                    trade_leg[trade_count] = leg_idx
                    trade_side[trade_count] = leg_side
                    trade_entry_px[trade_count] = leg_entry
                    trade_exit_px[trade_count] = exit_px
                    trade_move_pips[trade_count] = move_pips
                    trade_pnl_cents[trade_count] = pnl_cents
                    trade_reason[trade_count] = 3
                    trade_entry_regime[trade_count] = active_trade_regime
                    trade_count += 1
                    balance += pnl_cents
                    eq[eq_count] = balance
                    eq_count += 1
                    legs[leg_idx, 0] = 0.0
                    if leg_idx == 1:
                        leg_b_profit_hit = 1
                    continue

            # 4. Structural MR Exit (Clears all active legs)
            if leg_idx == 1 and enable_mr and current_regime == 3:
                for l_id in range(3):
                    if legs[l_id, 0] == 1.0:
                        m_pips = ((bar_close - legs[l_id, 3]) / pip_size_price) * int(legs[l_id, 2])
                        p_cents = m_pips * (legs[l_id, 1] * pip_value_per_1lot) - commission_cents
                        trade_entry_idx[trade_count] = int(legs[l_id, 6])
                        trade_exit_idx[trade_count] = i
                        trade_leg[trade_count] = l_id
                        trade_side[trade_count] = int(legs[l_id, 2])
                        trade_entry_px[trade_count] = legs[l_id, 3]
                        trade_exit_px[trade_count] = bar_close
                        trade_move_pips[trade_count] = m_pips
                        trade_pnl_cents[trade_count] = p_cents
                        trade_reason[trade_count] = 4
                        trade_entry_regime[trade_count] = active_trade_regime
                        trade_count += 1
                        balance += p_cents
                        legs[l_id, 0] = 0.0
                eq[eq_count] = balance
                eq_count += 1
                leg_a_profit_hit = 0
                leg_b_profit_hit = 0
                break

            # 5. Dynamic Time Stop (Clears all active legs)
            if leg_idx == 1 and enable_time_stop and time_stop_minutes > 0.0:
                if (time_minutes[i] - time_minutes[leg_entry_i]) >= time_stop_minutes:
                    for l_id in range(3):
                        if legs[l_id, 0] == 1.0:
                            m_pips = ((bar_close - legs[l_id, 3]) / pip_size_price) * int(legs[l_id, 2])
                            p_cents = m_pips * (legs[l_id, 1] * pip_value_per_1lot) - commission_cents
                            trade_entry_idx[trade_count] = int(legs[l_id, 6])
                            trade_exit_idx[trade_count] = i
                            trade_leg[trade_count] = l_id
                            trade_side[trade_count] = int(legs[l_id, 2])
                            trade_entry_px[trade_count] = legs[l_id, 3]
                            trade_exit_px[trade_count] = bar_close
                            trade_move_pips[trade_count] = m_pips
                            trade_pnl_cents[trade_count] = p_cents
                            trade_reason[trade_count] = 5
                            trade_entry_regime[trade_count] = active_trade_regime
                            trade_count += 1
                            balance += p_cents
                            legs[l_id, 0] = 0.0
                    eq[eq_count] = balance
                    eq_count += 1
                    leg_a_profit_hit = 0
                    leg_b_profit_hit = 0
                    break

    # EOD Cleanup
    if legs[0, 0] == 1.0 or legs[1, 0] == 1.0 or legs[2, 0] == 1.0:
        last_i = n - 1
        for leg_idx in range(3):
            if legs[leg_idx, 0] == 1.0:
                m_pips = ((close[last_i] - legs[leg_idx, 3]) / pip_size_price) * int(legs[leg_idx, 2])
                p_cents = m_pips * (legs[leg_idx, 1] * pip_value_per_1lot) - commission_cents
                trade_entry_idx[trade_count] = int(legs[leg_idx, 6])
                trade_exit_idx[trade_count] = last_i
                trade_leg[trade_count] = leg_idx
                trade_side[trade_count] = int(legs[leg_idx, 2])
                trade_entry_px[trade_count] = legs[leg_idx, 3]
                trade_exit_px[trade_count] = close[last_i]
                trade_move_pips[trade_count] = m_pips
                trade_pnl_cents[trade_count] = p_cents
                trade_reason[trade_count] = 6
                trade_entry_regime[trade_count] = active_trade_regime
                trade_count += 1
                balance += p_cents
        eq[eq_count] = balance
        eq_count += 1

    return (
        trade_entry_idx, trade_exit_idx, trade_leg, trade_side, trade_entry_px,
        trade_exit_px, trade_move_pips, trade_pnl_cents, trade_reason,
        trade_entry_regime, trade_count, eq, eq_count, balance
    )

def compute_metrics(trades_df: pd.DataFrame, equity_curve: list[float], initial_balance: float, ending_balance: float) -> dict:
    if trades_df.empty:
        return {
            "profit_factor": 0.0, "sharpe": 0.0, "sortino": 0.0, "calmar": 0.0, 
            "max_drawdown": 0.0, "expectancy": 0.0, "win_rate": 0.0, "trade_count": 0, 
            "net_profit": float(ending_balance - initial_balance), "profit_per_trade": 0.0, "net_return_pct": 0.0
        }

    pnl = trades_df["pnl_cents"].to_numpy(dtype=float)
    trade_count = int(len(pnl))
    net_profit = float(np.sum(pnl))
    gross_profit = float(np.sum(pnl[pnl > 0]))
    gross_loss = float(-np.sum(pnl[pnl < 0]))
    profit_factor = gross_profit / gross_loss if gross_loss > 0 else (10.0 if gross_profit > 0 else 0.0)
    win_rate = float(np.mean(pnl > 0))
    expectancy = float(np.mean(pnl))
    profit_per_trade = float(net_profit / max(trade_count, 1))

    r = pnl / max(initial_balance, 1e-9)
    r_std = float(np.std(r, ddof=0))
    sharpe = float((np.mean(r) / r_std) * math.sqrt(len(r))) if r_std > 0 else 0.0

    downside = r[r < 0]
    d_std = float(np.std(downside, ddof=0)) if len(downside) > 0 else 0.0
    sortino = float((np.mean(r) / d_std) * math.sqrt(len(r))) if d_std > 0 else 0.0

    eq = np.array(equity_curve, dtype=float)
    
    # RISK MANAGEMENT: Drawdown calculations based on an average of multiple positions
    window = min(3, len(eq))
    avg_eq = eq  # use raw equity for true peak-to-trough drawdown
    peaks = np.maximum.accumulate(avg_eq)
    dd = peaks - avg_eq
    max_dd_pct = float(np.max(dd / np.maximum(peaks, 1e-9)) * 100.0) if len(dd) > 0 else 0.0
    
    net_return_pct = float((ending_balance / initial_balance - 1.0) * 100.0)
    calmar = float(net_return_pct / max_dd_pct) if max_dd_pct > 0 else 0.0

    return {
        "profit_factor": profit_factor, "sharpe": sharpe, "sortino": sortino, "calmar": calmar, 
        "max_drawdown": max_dd_pct, "expectancy": expectancy, "win_rate": win_rate, "trade_count": trade_count, 
        "net_profit": net_profit, "profit_per_trade": profit_per_trade, "net_return_pct": net_return_pct
    }


def score_metrics(met: dict) -> float:
    return float(met.get("sharpe", 0.0))

def session_col_from_value(session_filter):
    if session_filter is None:
        return "session_mask_none"
    s = str(session_filter).lower()
    if s == "london":
        return "session_mask_london"
    if s == "ny":
        return "session_mask_ny"
    if s == "london_ny":
        return "session_mask_london_ny"
    raise ValueError(f"Unsupported session_filter: {session_filter}")

class TrendPullbackStrategy:
    name = "trend_pullback"

    def generate_signals(self, df: pd.DataFrame, params: dict) -> pd.Series:
        adx_threshold = float(params.get("adx_threshold", 15.0))
        pullback_rsi = float(params.get("pullback_rsi", 40.0))
        confirmation_bars = int(params.get("confirmation_bars", 1))
        session_filter = params.get("session_filter", None)
        session_col = session_col_from_value(session_filter)

        trend_up_raw = (df["macro_ema50"] > df["macro_ema200"]) & (df["macro_adx14"] > adx_threshold)
        trend_dn_raw = (df["macro_ema50"] < df["macro_ema200"]) & (df["macro_adx14"] > adx_threshold)

        if confirmation_bars > 1:
            trend_up = trend_up_raw.rolling(confirmation_bars, min_periods=confirmation_bars).sum().eq(confirmation_bars)
            trend_dn = trend_dn_raw.rolling(confirmation_bars, min_periods=confirmation_bars).sum().eq(confirmation_bars)
        else:
            trend_up, trend_dn = trend_up_raw, trend_dn_raw

        long_cond = trend_up & (df["rsi5"] < pullback_rsi) & df[session_col].astype(bool)
        short_cond = trend_dn & (df["rsi5"] > (100.0 - pullback_rsi)) & df[session_col].astype(bool)

        sig = pd.Series(0, index=df.index, dtype=np.int8)
        sig.loc[long_cond.fillna(False)] = 1
        sig.loc[short_cond.fillna(False)] = -1
        return sig

def run_ml_filtered_backtest(timeframe: str, df: pd.DataFrame, ml_probs: np.ndarray, base_params: dict, xgb_threshold: float):
    strategy = TrendPullbackStrategy()
    raw_signals = strategy.generate_signals(df, base_params)
    # Route signals: only trade TREND regime (regime_code == 1) on lower timeframes
    trend_mask = df["regime_code"].to_numpy() == 1
    raw_signals = raw_signals.where(trend_mask, 0)
    
    
    raw_arr = raw_signals.to_numpy(dtype=np.int8)
    prob_down = ml_probs[:, 0]
    prob_up = ml_probs[:, 2]
    
    filtered_arr = raw_arr.copy()
    filtered_arr[(raw_arr == 1) & (prob_up < xgb_threshold)] = 0
    filtered_arr[(raw_arr == -1) & (prob_down < xgb_threshold)] = 0

    idx = df.index
    time_minutes = (idx.view("int64") // 60000000000).astype(np.int64)
    sig = filtered_arr
    
    high = df["High"].to_numpy(dtype=np.float64)
    low = df["Low"].to_numpy(dtype=np.float64)
    close = df["Close"].to_numpy(dtype=np.float64)
    spread = df["spread"].fillna(40.0).to_numpy(dtype=np.float64)
    atr14 = df["atr14"].to_numpy(dtype=np.float64)
    regime_code = df["regime_code"].to_numpy(dtype=np.int32)
    
    is_m5 = 1 if timeframe == "M5" else 0
    exit_model_map = {"fixed_tp": 0, "mr_exit": 1, "fixed_tp_plus_mr": 2, "partial_tp_plus_mr": 3, "partial_tp_mr_time_stop": 4}
    exit_mode_code = int(exit_model_map.get(base_params.get("exit_model", "fixed_tp"), 0))

    initial_bal = INITIAL_BALANCE_CENTS

    # BRIDGE REPAIR: Smart-check multiple common dictionary key variants exported by Strategy Tester
    sl_dist = float(base_params.get("atr_stop", base_params.get("sl_atr", base_params.get("atr_mult_sl", base_params.get("stop_loss_multiplier", 2.0)))))
    tp_dist = float(base_params.get("atr_target", base_params.get("tp_atr", base_params.get("atr_mult_tp", base_params.get("take_profit_multiplier", 2.0)))))
    leg_a_tp = float(base_params.get("leg_a_atr_target", base_params.get("leg_a_tp", 1.0)))

    (trade_entry_idx, trade_exit_idx, trade_leg, trade_side, trade_entry_px,
     trade_exit_px, trade_move_pips, trade_pnl_cents, trade_reason,
     trade_entry_regime, trade_count, eq, eq_count, ending_balance) = _run_backtest_numba(
        sig, high, low, close, spread, atr14, regime_code, time_minutes,
        sl_dist, tp_dist, leg_a_tp, exit_mode_code, 
        float(base_params.get("time_stop_minutes", -1.0)), float(base_params.get("trail_mult", 0.0)),
        float(initial_bal), 0.10, 100.0, 0.30, 40.0, 0.0, int(is_m5)
    )

    if trade_count == 0:
        return pd.DataFrame(), compute_metrics(pd.DataFrame(), [], initial_bal, float(ending_balance))
    
    trades_df = pd.DataFrame({"pnl_cents": trade_pnl_cents[:trade_count]})
    return trades_df, compute_metrics(trades_df, eq[:eq_count].tolist(), initial_bal, float(ending_balance))
In [9]:
# -----------------------------
# CPCV combo evaluator for M15 Trend + M5 Execution strategy
# -----------------------------
import math
import time
import warnings

def _empty_combo_result(xgb_threshold, exec_tf):
    return {
        "timeframe": exec_tf,
        "xgb_threshold": float(xgb_threshold),
        "mean_sharpe": 0.0,
        "mean_sharpe_raw": 0.0,
        "variance_sharpe": 0.0,
        "stability_adjusted_sharpe": 0.0,
        "turnover_penalty": 0.0,
        "mean_trades_per_100": 0.0,
        "n_paths": 0,
        "median_trades": 0,
    }

# Timeframe-scoped cache (Phase 7: no global mutable state)
_ML_FOLD_CACHE = {tf: {} for tf in TIMEFRAMES}

def _get_or_compute_ml_folds(exec_df, n_blocks, k_val_blocks, embargo_bars, exec_tf):
    tf_cache = _ML_FOLD_CACHE.get(exec_tf, {})

    cache_key = f"{exec_tf}_{id(exec_df)}_{n_blocks}_{k_val_blocks}"
    if cache_key in tf_cache:
        return tf_cache[cache_key]

    print(f"\n[!] Pre-computing Features and ML Models for {exec_tf}...")

    t_start = time.time()
    d_exec = exec_df.sort_index().copy()

    base_params = dict(ML_TARGET_PARAMS[exec_tf]["parameters"])
    base_params["exit_model"] = ML_TARGET_PARAMS[exec_tf]["exit_model"]

    feat = build_features(d_exec, exec_tf)
    event_end_pos = feat["event_end_pos"].to_numpy(dtype=np.int32)
    hmm_cols = hmm_feature_columns(feat)

    splitter = CPCVPurgedEmbargo(
        n_blocks=int(n_blocks),
        k_val_blocks=int(k_val_blocks),
        embargo_bars=int(embargo_bars),
    )

    folds_data = []
    splits = list(splitter.split(len(feat), event_end_pos))

    for fold_idx, (train_idx, val_idx) in enumerate(splits, 1):
        if len(train_idx) < 200 or len(val_idx) < 250:
            continue

        print(f" -> Training ML Fold {fold_idx}/{len(splits)}...")
        train_features = feat.iloc[train_idx]
        val_features = feat.iloc[val_idx]
        train_labels = train_features["tb_label"]

        model = HMMXGBComposite(random_state=RANDOM_STATE)
        model.fit(train_features, train_labels, hmm_features=hmm_cols)
        ml_probs = model.predict_proba_raw(val_features)

        folds_data.append((val_features, ml_probs))

    print(f"Pre-computation for {exec_tf} complete in {(time.time()-t_start)/60:.1f} minutes.\n")

    cache_data = {
        "folds_data": folds_data,
        "base_params": base_params
    }
    _ML_FOLD_CACHE[exec_tf][cache_key] = cache_data
    return cache_data

def evaluate_combo_cpcv(
    exec_df: pd.DataFrame,
    xgb_threshold: float,
    n_blocks: int,
    k_val_blocks: int,
    embargo_bars: int,
    exec_tf: str
) -> dict:
    warnings.filterwarnings("ignore")

    if not isinstance(ML_TARGET_PARAMS, dict) or ML_TARGET_PARAMS.get(exec_tf) is None:
        return _empty_combo_result(xgb_threshold, exec_tf)

    cache_data = _get_or_compute_ml_folds(exec_df, n_blocks, k_val_blocks, embargo_bars, exec_tf)
    folds_data = cache_data["folds_data"]
    base_params = cache_data["base_params"]

    path_scores = []
    path_trades = []
    path_t100 = []

    for val_features, ml_probs in folds_data:
        _, met = run_ml_filtered_backtest(
            timeframe=exec_tf,
            df=val_features,
            ml_probs=ml_probs,
            base_params=base_params,
            xgb_threshold=float(xgb_threshold),
        )

        path_scores.append(float(met.get("sharpe", 0.0)))
        n_trades = int(met.get("trade_count", 0))
        path_trades.append(n_trades)
        path_t100.append(float((n_trades / max(len(val_features), 1)) * 100.0))

    if len(path_scores) == 0:
        return _empty_combo_result(xgb_threshold, exec_tf)

    arr = np.array(path_scores, dtype=float)
    mean_raw = float(np.mean(arr))
    var_score = float(np.var(arr))
    mean_t100 = float(np.mean(np.array(path_t100, dtype=float)))

    limit = float(4.0)
    lam = float(3.0)
    excess = max(0.0, mean_t100 - limit)
    turnover_penalty = float(lam * excess)

    mean_score = float(mean_raw - turnover_penalty)
    stab = float(mean_score / (math.sqrt(max(var_score, 1e-12)) + 1e-6))

    return {
        "timeframe": exec_tf,
        "xgb_threshold": float(xgb_threshold),
        "mean_sharpe": float(mean_score),
        "mean_sharpe_raw": float(mean_raw),
        "variance_sharpe": float(var_score),
        "stability_adjusted_sharpe": float(stab),
        "turnover_penalty": float(turnover_penalty),
        "mean_trades_per_100": float(mean_t100),
        "n_paths": int(len(path_scores)),
        "median_trades": int(np.median(np.array(path_trades, dtype=int))),
    }

def run_grid_parallel(
    exec_df: pd.DataFrame,
    grid: list[tuple[float]],
    n_blocks: int,
    k_val_blocks: int,
    embargo_bars: int,
    exec_tf: str,
    n_jobs: int = 1,
) -> pd.DataFrame:
    t0 = time.time()
    _get_or_compute_ml_folds(exec_df, n_blocks, k_val_blocks, embargo_bars, exec_tf)

    out_local = []
    for i, g in enumerate(grid, 1):
        xgb_threshold = float(g[0])
        res = evaluate_combo_cpcv(exec_df, xgb_threshold, n_blocks, k_val_blocks, embargo_bars, exec_tf)
        out_local.append(res)

    df_out = pd.DataFrame(out_local)
    elapsed = time.time() - t0
    print(f"Grid sweep for {exec_tf} complete in {elapsed:.2f} seconds")
    return df_out
In [10]:
# -----------------------------
# Strategy parameter plateau tools
# -----------------------------

def build_coarse_grid():
    # Grid searches ML confidence threshold
    # Lowered to realistic financial ML probability ranges for minority classes
    xgb_threshold_grid = [0.10, 0.15, 0.20, 0.25, 0.30]
    return [(float(x),) for x in xgb_threshold_grid]

def build_refined_grid_from_top(
    coarse_df: pd.DataFrame,
    top_k: int = 3,
    step: float = 0.02,
) -> list[tuple[float]]:
    cd = coarse_df.copy()
    cd = cd[np.isfinite(cd["stability_adjusted_sharpe"])].sort_values(
        ["stability_adjusted_sharpe", "mean_sharpe"], ascending=False
    )
    top = cd.head(top_k)

    refined = set()
    for _, r in top.iterrows():
        center = float(r["xgb_threshold"])
        for delta in (-2 * step, -step, 0.0, step, 2 * step):
            cand = round(center + delta, 4)
            # Bounded to match the lowered threshold reality
            if 0.05 <= cand <= 0.45:
                refined.add((float(cand),))

    return sorted(list(refined))

def plot_plateau_heatmaps(results_df: pd.DataFrame, title_prefix: str = "Strategy Surface"):
    if results_df.empty:
        print("No results to plot")
        return

    d = results_df.copy()
    d = d[np.isfinite(d["mean_sharpe"]) & np.isfinite(d["xgb_threshold"])]
    if d.empty:
        print("No finite results to plot")
        return

    d = d.sort_values("xgb_threshold")
    plt.figure(figsize=(8, 4))
    plt.plot(d["xgb_threshold"], d["mean_sharpe"], marker="o", label="mean_sharpe")
    if "stability_adjusted_sharpe" in d.columns:
        plt.plot(d["xgb_threshold"], d["stability_adjusted_sharpe"], marker="s", label="stability_adjusted_sharpe")
    plt.axhline(0.0, color="gray", linestyle="--", linewidth=0.8)
    plt.title(f"{title_prefix} | xgb_threshold vs score")
    plt.xlabel("xgb_threshold")
    plt.ylabel("score")
    plt.legend()
    plt.tight_layout()
    plt.show()


def select_plateau_center(fine_df: pd.DataFrame, min_mean_sharpe: float = -1e9, neighbor_width: float = 0.05) -> dict | None:
    d = fine_df.copy()
    d = d[np.isfinite(d["mean_sharpe"]) & np.isfinite(d["stability_adjusted_sharpe"])]
    d = d[d["mean_sharpe"] >= float(min_mean_sharpe)]
    if d.empty:
        return None

    best = None
    best_score = -1e9

    for _, row in d.iterrows():
        thr = float(row["xgb_threshold"])
        neigh = d[d["xgb_threshold"].between(thr - neighbor_width, thr + neighbor_width)]

        plateau_width = int((neigh["mean_sharpe"] > 0).sum())
        local_stab = float(neigh["stability_adjusted_sharpe"].mean())
        local_mean = float(neigh["mean_sharpe"].mean())
        score = plateau_width * 1.5 + local_stab + 0.15 * local_mean

        if score > best_score:
            best_score = score
            best = {
                "xgb_threshold": thr,
                "mean_sharpe": float(row["mean_sharpe"]),
                "mean_sharpe_raw": float(row.get("mean_sharpe_raw", np.nan)),
                "variance_sharpe": float(row["variance_sharpe"]),
                "stability_adjusted_sharpe": float(row["stability_adjusted_sharpe"]),
                "turnover_penalty": float(row.get("turnover_penalty", 0.0)),
                "mean_trades_per_100": float(row.get("mean_trades_per_100", np.nan)),
                "plateau_width_local": plateau_width,
                "selection_score": float(score),
            }

    return best
In [11]:
# -----------------------------
# Main run: load M5 + M15 data and split into pipeline containers
# Phase 5:  Uses split_dataset for consistent splits
# Phase 11: Data stored in pipeline container, not loose globals
# -----------------------------

for tf in TIMEFRAMES:
    pipeline[tf].raw_all = load_panel(tf).sort_index().copy()

# --- IS / OOS chronological split ---
# M5 is the reference: its split determines split_time.
# M15 aligns to the same split_time boundary so both timeframes
# share a consistent cut-off.

m5_is, m5_oos, split_time = split_dataset(pipeline["M5"].raw_all, HOLDOUT_FRAC)
pipeline["M5"].train_df = m5_is
pipeline["M5"].oos_df = m5_oos
pipeline["M5"].split_time = split_time

# Align M15 to the same split_time boundary
m15_all_sorted = pipeline["M15"].raw_all.sort_index()
pipeline["M15"].train_df = m15_all_sorted.loc[m15_all_sorted.index <= split_time].copy()
pipeline["M15"].oos_df = m15_all_sorted.loc[m15_all_sorted.index > split_time].copy()
pipeline["M15"].split_time = split_time

print("=" * 60)
print("IS / OOS DATA SPLIT SUMMARY")
print("=" * 60)
print("Holdout fraction:  %.0f%%  (OOS)" % (HOLDOUT_FRAC * 100))
print("Split timestamp:  ", split_time)
for tf in TIMEFRAMES:
    p = pipeline[tf]
    total = len(p.raw_all)
    print("\n  %s total:  %d" % (tf, total))
    print("  %s IS   :  %d   (%.1f%%)   %s -> %s" % (tf, len(p.train_df), len(p.train_df)/total*100, p.train_df.index[0].strftime('%Y-%m-%d'), p.train_df.index[-1].strftime('%Y-%m-%d')))
    print("  %s OOS  :  %d   (%.1f%%)   %s -> %s" % (tf, len(p.oos_df), len(p.oos_df)/total*100, p.oos_df.index[0].strftime('%Y-%m-%d'), p.oos_df.index[-1].strftime('%Y-%m-%d')))
print()
print("Note: OOS typically has fewer trades because (1) it covers")
print("      a shorter calendar span (%.0f%% of data) and (2) regime" % (HOLDOUT_FRAC * 100))
print("      mix may differ, affecting signal generation.")
print("=" * 60)

# Backward-compatible references for cells not yet fully refactored
m5_train = pipeline["M5"].train_df
m5_oos = pipeline["M5"].oos_df
m15_train = pipeline["M15"].train_df
m15_oos = pipeline["M15"].oos_df
m5_all = pipeline["M5"].raw_all
m15_all = pipeline["M15"].raw_all
  [M15] Dropping 259376 rows with no XAG or XTI data (before their start dates)
  [M15] MAX_DATA_YEARS=5: 243187 → 116101 rows (cutoff 2021-05-29)
  [M5] Dropping 742297 rows with no XAG or XTI data (before their start dates)
  [M5] MAX_DATA_YEARS=5: 726089 → 347571 rows (cutoff 2021-05-29)
============================================================
IS / OOS DATA SPLIT SUMMARY
============================================================
Holdout fraction:  20%  (OOS)
Split timestamp:   2025-05-05 03:00:00

  M15 total:  116101
  M15 IS   :  92881   (80.0%)   2021-05-31 -> 2025-05-05
  M15 OOS  :  23220   (20.0%)   2025-05-05 -> 2026-05-29

  M5 total:  347571
  M5 IS   :  278056   (80.0%)   2021-05-31 -> 2025-05-05
  M5 OOS  :  69515   (20.0%)   2025-05-05 -> 2026-05-29

Note: OOS typically has fewer trades because (1) it covers
      a shorter calendar span (20% of data) and (2) regime
      mix may differ, affecting signal generation.
============================================================
In [12]:
# -----------------------------
# Coarse pass
# -----------------------------
coarse_grid = build_coarse_grid()
coarse_results_dict = {}

for tf in ["M15", "M5"]:
    print(f"\n--- COARSE PASS ({tf}) ---")
    active_train_df = m5_train if tf == "M5" else m15_train

    res = run_grid_parallel(
        exec_df=active_train_df,
        grid=coarse_grid,
        n_blocks=4,
        k_val_blocks=1,
        embargo_bars=12,
        exec_tf=tf,
        n_jobs=N_JOBS
    )

    coarse_results_dict[tf] = res
    print(f"\n{tf} Coarse Results Top 3:")
    display(res.sort_values(["stability_adjusted_sharpe", "mean_sharpe"], ascending=False).head(3))

# Combine all results
coarse_results = pd.concat(coarse_results_dict.values(), ignore_index=True)
--- COARSE PASS (M15) ---

[!] Pre-computing Features and ML Models for M15...
 -> Training ML Fold 1/4...
  HMM converged (diag/0.01) LL=-1209876.2
 -> Training ML Fold 2/4...
  HMM converged (diag/0.01) LL=-1205230.3
 -> Training ML Fold 3/4...
  HMM converged (diag/0.01) LL=-1207905.8
 -> Training ML Fold 4/4...
  HMM converged (diag/0.01) LL=-1196579.1
Pre-computation for M15 complete in 1.7 minutes.

Grid sweep for M15 complete in 99.63 seconds

M15 Coarse Results Top 3:
timeframe xgb_threshold mean_sharpe mean_sharpe_raw variance_sharpe stability_adjusted_sharpe turnover_penalty mean_trades_per_100 n_paths median_trades
0 M15 0.10 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
1 M15 0.15 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
2 M15 0.20 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
--- COARSE PASS (M5) ---

[!] Pre-computing Features and ML Models for M5...
 -> Training ML Fold 1/4...
  HMM converged (diag/0.01) LL=-3561492.0
 -> Training ML Fold 2/4...
  HMM converged (diag/0.01) LL=-3538061.4
 -> Training ML Fold 3/4...
  HMM converged (diag/0.01) LL=-3563862.1
 -> Training ML Fold 4/4...
  HMM converged (diag/0.01) LL=-3516858.4
Pre-computation for M5 complete in 5.3 minutes.

Grid sweep for M5 complete in 316.47 seconds

M5 Coarse Results Top 3:
timeframe xgb_threshold mean_sharpe mean_sharpe_raw variance_sharpe stability_adjusted_sharpe turnover_penalty mean_trades_per_100 n_paths median_trades
0 M5 0.10 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
1 M5 0.15 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
2 M5 0.20 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
In [13]:
# -----------------------------
# Fine pass
# -----------------------------
fine_results_dict = {}

for tf in ["M15", "M5"]:
    print(f"\n--- FINE PASS ({tf}) ---")
    active_train_df = m5_train if tf == "M5" else m15_train
    tf_coarse = coarse_results[coarse_results["timeframe"] == tf]

    refined_grid = build_refined_grid_from_top(tf_coarse, top_k=3, step=0.02)

    res = run_grid_parallel(
        exec_df=active_train_df,
        grid=refined_grid,
        n_blocks=4,
        k_val_blocks=1,
        embargo_bars=12,
        exec_tf=tf,
        n_jobs=N_JOBS
    )

    fine_results_dict[tf] = res
    print(f"\n{tf} Fine Results Top 3:")
    display(res.sort_values(["stability_adjusted_sharpe", "mean_sharpe"], ascending=False).head(3))

# Combine all results
fine_results = pd.concat(fine_results_dict.values(), ignore_index=True)
--- FINE PASS (M15) ---
Grid sweep for M15 complete in 0.52 seconds

M15 Fine Results Top 3:
timeframe xgb_threshold mean_sharpe mean_sharpe_raw variance_sharpe stability_adjusted_sharpe turnover_penalty mean_trades_per_100 n_paths median_trades
0 M15 0.06 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
1 M15 0.08 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
2 M15 0.10 2.565013 2.565013 0.811948 2.84659 0.0 0.821088 4 191
--- FINE PASS (M5) ---
Grid sweep for M5 complete in 0.79 seconds

M5 Fine Results Top 3:
timeframe xgb_threshold mean_sharpe mean_sharpe_raw variance_sharpe stability_adjusted_sharpe turnover_penalty mean_trades_per_100 n_paths median_trades
0 M5 0.06 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
1 M5 0.08 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
2 M5 0.10 4.772342 4.772342 0.373687 7.806868 0.0 0.765142 4 543
In [14]:
# -----------------------------
# Sanity checks
# -----------------------------

print("Fine unique n_paths:", sorted(fine_results["n_paths"].dropna().unique().tolist()))
print("Fine mean trades/100 (top 10):", fine_results["mean_trades_per_100"].head(10).round(3).tolist())
print("Fine turnover penalty (top 10):", fine_results["turnover_penalty"].head(10).round(3).tolist())

print("\nTop 10 with raw vs penalized:")
display(
    fine_results[
        [
            "xgb_threshold",
            "mean_sharpe_raw",
            "turnover_penalty",
            "mean_sharpe",
            "stability_adjusted_sharpe",
            "mean_trades_per_100",
        ]
    ].head(10)
)
Fine unique n_paths: [4]
Fine mean trades/100 (top 10): [0.821, 0.821, 0.821, 0.821, 0.821, 0.821, 0.821, 0.821, 0.821, 0.821]
Fine turnover penalty (top 10): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

Top 10 with raw vs penalized:
xgb_threshold mean_sharpe_raw turnover_penalty mean_sharpe stability_adjusted_sharpe mean_trades_per_100
0 0.06 2.565013 0.0 2.565013 2.84659 0.821088
1 0.08 2.565013 0.0 2.565013 2.84659 0.821088
2 0.10 2.565013 0.0 2.565013 2.84659 0.821088
3 0.11 2.565013 0.0 2.565013 2.84659 0.821088
4 0.12 2.565013 0.0 2.565013 2.84659 0.821088
5 0.13 2.565013 0.0 2.565013 2.84659 0.821088
6 0.14 2.565013 0.0 2.565013 2.84659 0.821088
7 0.15 2.565013 0.0 2.565013 2.84659 0.821088
8 0.16 2.565013 0.0 2.565013 2.84659 0.821088
9 0.17 2.565013 0.0 2.565013 2.84659 0.821088
In [15]:
# -----------------------------
# Model lifecycle + Final model selection + IS/OOS validation
# Phase 2-3: Separate train_ml_model from evaluate_ml_model
# Phase 4:   No global best timeframe -- both TFs export independently
# Phase 6-7: Pipeline-scoped registry instead of global variables
# Phase 8:   Validation loop uses pre-trained models (no retraining)
# Phase 5:   Consumes split from pipeline container (split_dataset)
# -----------------------------
import warnings

def _safe_float(v, default=0.0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return default

def _safe_int(v, default=0):
    try:
        return int(v)
    except (TypeError, ValueError):
        return default

def _normalize_metrics(met, ending_balance):
    out = dict(met)
    out["max_drawdown_pct"] = _safe_float(met.get("max_drawdown", met.get("max_drawdown_pct", 0.0)), 0.0)
    out["n_trades"] = _safe_int(met.get("trade_count", met.get("n_trades", 0)), 0)
    out["ending_balance_cents"] = _safe_float(ending_balance, 0.0)
    return out


# -----------------------------
# Phase 2-3: train_ml_model and evaluate_ml_model
# -----------------------------
def train_ml_model(train_data, exec_tf):
    "Train an HMMXGBComposite model on training data ONCE."
    warnings.filterwarnings("ignore")
    tf = exec_tf.upper()
    if ML_TARGET_PARAMS.get(tf) is None:
        raise RuntimeError("No bridge baseline for %s" % tf)

    feat = build_features(train_data, tf)
    hmm_feats = hmm_feature_columns(feat)

    model = HMMXGBComposite(random_state=RANDOM_STATE)
    model.fit(feat, feat["tb_label"], hmm_features=hmm_feats)
    print("  [%s] Model trained on %d samples, %d HMM features" % (tf, len(feat), len(hmm_feats)))
    return model, feat


def evaluate_ml_model(trained_model, data, exec_tf, xgb_threshold):
    "Evaluate a PRE-TRAINED model on provided data. NO training occurs here."
    warnings.filterwarnings("ignore")
    tf = exec_tf.upper()
    if ML_TARGET_PARAMS.get(tf) is None:
        raise RuntimeError("No bridge baseline for %s" % tf)

    base_params = dict(ML_TARGET_PARAMS[tf]["parameters"])
    base_params["exit_model"] = ML_TARGET_PARAMS[tf]["exit_model"]

    feat = build_features(data, tf)

    # Inference only -- no model.fit() call
    probs = trained_model.predict_proba_raw(feat)

    trades, met = run_ml_filtered_backtest(tf, feat, probs, base_params, float(xgb_threshold))
    ending_balance = _safe_float(INITIAL_BALANCE_CENTS + _safe_float(met.get("net_profit", 0.0)), INITIAL_BALANCE_CENTS)
    return trades, _normalize_metrics(met, ending_balance)


def _select_center_with_fallback(tf_fine):
    "Plateau-center selection with the original fallback logic, per TF."
    center = select_plateau_center(tf_fine, min_mean_sharpe=-1e9)
    if center is not None:
        return center

    print("Standard center selection failed. Attempting fallback...")
    d = tf_fine.copy()
    for col in ["xgb_threshold", "mean_sharpe", "stability_adjusted_sharpe"]:
        if col in d.columns:
            d[col] = pd.to_numeric(d[col], errors="coerce")

    d = d[np.isfinite(d["mean_sharpe"]) & np.isfinite(d["stability_adjusted_sharpe"])]
    if d.empty:
        raise RuntimeError("No valid fine results found after filtering.")

    row = d.sort_values(
        ["stability_adjusted_sharpe", "mean_sharpe"],
        ascending=False,
        na_position="last",
    ).iloc[0]

    center = {
        "xgb_threshold": float(row["xgb_threshold"]),
        "mean_sharpe": float(row["mean_sharpe"]),
        "stability_adjusted_sharpe": float(row["stability_adjusted_sharpe"]),
        "selection_score": float(row["stability_adjusted_sharpe"]),
    }
    print("Fallback center selected:")
    print(center)
    return center


# ---------------------------------------------------------------
# 1. Select the best plateau center PER timeframe (Phase 4)
#    NO global "best timeframe" selection.
#    Each TF stores its own threshold and plateau in the pipeline.
# ---------------------------------------------------------------
for tf in TIMEFRAMES:
    tf_fine = fine_results[fine_results["timeframe"] == tf].copy()
    if tf_fine.empty:
        raise RuntimeError("No fine results for timeframe %s." % tf)

    print("\n[%s] Selected plateau center:" % tf)
    center = _select_center_with_fallback(tf_fine)
    print(center)

    thr = _safe_float(center.get("xgb_threshold"), np.nan)
    if np.isnan(thr):
        raise RuntimeError("Invalid xgb_threshold selected for %s: %r" % (tf, center.get('xgb_threshold')))

    pipeline[tf].plateau = center
    pipeline[tf].threshold = thr
    print("[%s] Threshold stored in pipeline: %s" % (tf, thr))

# ---------------------------------------------------------------
# 2. Train models PER timeframe (Phase 2-3: train once)
#    Models are stored in pipeline[tf].model.
#    They will NOT be retrained during evaluation.
# ---------------------------------------------------------------
print("\nTraining models (one per timeframe, frozen after this)...")
for tf in TIMEFRAMES:
    p = pipeline[tf]
    print("\n[%s] Training on IS data (%d rows)..." % (tf, len(p.train_df)))
    model, feat = train_ml_model(p.train_df, tf)
    p.model = model
    p.train_feat = feat
    print("[%s] Model frozen and stored in pipeline" % tf)

# ---------------------------------------------------------------
# 3. Evaluate models: IS and OOS using pre-trained models (Phase 8)
#    Training must never occur here.
# ---------------------------------------------------------------
print("\nRunning final validation segments for both timeframes (inference only)...")

validation_rows = []

for tf in TIMEFRAMES:
    p = pipeline[tf]
    thr = float(p.threshold)

    print("\n[%s] threshold=%s  (train rows=%d, oos rows=%d)" % (tf, thr, len(p.train_df), len(p.oos_df)))

    # IS evaluation -- uses pre-trained model
    trades_is, met_is = evaluate_ml_model(p.model, p.train_df, tf, thr)
    p.trades_is = trades_is
    p.metrics_is = met_is

    # OOS evaluation -- uses SAME pre-trained model (no retraining!)
    trades_oos, met_oos = evaluate_ml_model(p.model, p.oos_df, tf, thr)
    p.trades_oos = trades_oos
    p.metrics_oos = met_oos

    score_is = score_metrics(met_is)
    score_oos = score_metrics(met_oos)
    rel_drop = (score_is - score_oos) / (abs(score_is) + 1e-9)

    validation_rows.extend([
        {
            "timeframe": tf,
            "scenario": "IS",
            "xgb_threshold": thr,
            "score": score_is,
            "win_rate": met_is.get("win_rate", 0),
            "profit_factor": met_is.get("profit_factor", 0),
            "max_drawdown_pct": met_is.get("max_drawdown_pct", 0),
            "n_trades": met_is.get("n_trades", 0),
            "ending_balance_cents": met_is.get("ending_balance_cents", 0),
            "net_return_pct": met_is.get("net_return_pct", 0),
            "relative_score_drop_is_to_oos": rel_drop,
        },
        {
            "timeframe": tf,
            "scenario": "OOS",
            "xgb_threshold": thr,
            "score": score_oos,
            "win_rate": met_oos.get("win_rate", 0),
            "profit_factor": met_oos.get("profit_factor", 0),
            "max_drawdown_pct": met_oos.get("max_drawdown_pct", 0),
            "n_trades": met_oos.get("n_trades", 0),
            "ending_balance_cents": met_oos.get("ending_balance_cents", 0),
            "net_return_pct": met_oos.get("net_return_pct", 0),
            "relative_score_drop_is_to_oos": rel_drop,
        },
    ])

# 4. Output summary
validation_summary = pd.DataFrame(validation_rows)

print("\nValidation summary (both timeframes):")
display(validation_summary)

print("\nRelative score drop IS->OOS:")
for tf in TIMEFRAMES:
    row = validation_summary[
        (validation_summary["timeframe"] == tf) & (validation_summary["scenario"] == "OOS")
    ].iloc[0]
    drop = float(row["relative_score_drop_is_to_oos"])
    warn = "  <- WARNING: >30% drop" if drop > 0.30 else ""
    print("  %s: %.2f%%%s" % (tf, drop * 100, warn))

if not validation_summary.empty:
    validation_summary.to_csv("notebooks/reports/final_is_oos_summary.csv", index=False)
    print("\nSaved: notebooks/reports/final_is_oos_summary.csv")
[M15] Selected plateau center:
{'xgb_threshold': 0.15, 'mean_sharpe': 2.565012682210938, 'mean_sharpe_raw': 2.565012682210938, 'variance_sharpe': 0.8119481470966367, 'stability_adjusted_sharpe': 2.8465897894593244, 'turnover_penalty': 0.0, 'mean_trades_per_100': 0.8210877406977047, 'plateau_width_local': 11, 'selection_score': 19.731341691790963}
[M15] Threshold stored in pipeline: 0.15

[M5] Selected plateau center:
{'xgb_threshold': 0.15, 'mean_sharpe': 4.772341753955372, 'mean_sharpe_raw': 4.772341753955372, 'variance_sharpe': 0.3736869615349951, 'stability_adjusted_sharpe': 7.806868311265183, 'turnover_penalty': 0.0, 'mean_trades_per_100': 0.7651421054065681, 'plateau_width_local': 11, 'selection_score': 25.02271957435849}
[M5] Threshold stored in pipeline: 0.15

Training models (one per timeframe, frozen after this)...

[M15] Training on IS data (92881 rows)...
  HMM converged (diag/0.01) LL=-1607520.1
  [M15] Model trained on 92682 samples, 16 HMM features
[M15] Model frozen and stored in pipeline

[M5] Training on IS data (278056 rows)...
  HMM converged (diag/0.01) LL=-4728786.5
  [M5] Model trained on 277857 samples, 16 HMM features
[M5] Model frozen and stored in pipeline

Running final validation segments for both timeframes (inference only)...

[M15] threshold=0.15  (train rows=92881, oos rows=23220)

[M5] threshold=0.15  (train rows=278056, oos rows=69515)

Validation summary (both timeframes):
timeframe scenario xgb_threshold score win_rate profit_factor max_drawdown_pct n_trades ending_balance_cents net_return_pct relative_score_drop_is_to_oos
0 M15 IS 0.15 5.342374 0.544021 2.542452 23.193496 761 22749.957737 1416.663849 0.739842
1 M15 OOS 0.15 1.389863 0.476923 7.071726 11.386829 65 14502.293382 866.819559 0.739842
2 M5 IS 0.15 9.423663 0.585882 2.800003 6.412390 2125 44104.640315 2840.309354 0.540412
3 M5 OOS 0.15 4.331005 0.601202 3.205775 24.391260 499 36401.894862 2326.792991 0.540412
Relative score drop IS->OOS:
  M15: 73.98%  <- WARNING: >30% drop
  M5: 54.04%  <- WARNING: >30% drop

Saved: notebooks/reports/final_is_oos_summary.csv
In [16]:
# -----------------------------
# Dual timeframe validation (consolidated): M15 and M5 independently
# Phase 4:  Each TF uses its own threshold from pipeline
# Phase 8:  Uses pre-trained models from pipeline (no retraining)
# Phase 5:  Uses split_dataset for consistent splits
# Phase 7:  No globals() calls
# Phase 11: All data from pipeline container
# -----------------------------

def run_mode_v2(tf):
    "Run IS/OOS validation for a single timeframe using pipeline state."
    p = pipeline[tf]
    model = p.model
    thr = float(p.threshold)

    # Re-split from raw_all using centralised split_dataset
    train_df, oos_df, _split_time = split_dataset(p.raw_all, HOLDOUT_FRAC)

    # IS evaluation with pre-trained model
    trades_is, met_is = evaluate_ml_model(model, train_df, tf, thr)
    score_is = score_metrics(met_is)

    # OOS evaluation with SAME pre-trained model
    trades_oos, met_oos = evaluate_ml_model(model, oos_df, tf, thr)
    score_oos = score_metrics(met_oos)

    rel_drop = float((score_is - score_oos) / (abs(score_is) + 1e-9))

    rows = [
        {
            "timeframe_mode": "%s_only" % tf,
            "scenario": "IS",
            "xgb_threshold": thr,
            "score": float(score_is),
            "win_rate": float(met_is.get("win_rate", 0)),
            "profit_factor": float(met_is.get("profit_factor", 0)),
            "max_drawdown_pct": float(met_is.get("max_drawdown_pct", 0)),
            "n_trades": int(met_is.get("trade_count", met_is.get("n_trades", 0))),
            "ending_balance_cents": float(met_is.get("ending_balance_cents", 0)),
            "net_return_pct": float(met_is.get("net_return_pct", 0)),
            "relative_score_drop_is_to_oos": rel_drop,
            "split_time": _split_time,
            "exec_rows": int(len(train_df)),
        },
        {
            "timeframe_mode": "%s_only" % tf,
            "scenario": "OOS",
            "xgb_threshold": thr,
            "score": float(score_oos),
            "win_rate": float(met_oos.get("win_rate", 0)),
            "profit_factor": float(met_oos.get("profit_factor", 0)),
            "max_drawdown_pct": float(met_oos.get("max_drawdown_pct", 0)),
            "n_trades": int(met_oos.get("trade_count", met_oos.get("n_trades", 0))),
            "ending_balance_cents": float(met_oos.get("ending_balance_cents", 0)),
            "net_return_pct": float(met_oos.get("net_return_pct", 0)),
            "relative_score_drop_is_to_oos": rel_drop,
            "split_time": _split_time,
            "exec_rows": int(len(oos_df)),
        },
    ]
    return rows, trades_is, trades_oos


all_rows = []
trades_by_mode = {}

for tf in TIMEFRAMES:
    thr = pipeline[tf].threshold
    print("Running %s validation with threshold=%s" % (tf, thr))
    rows, t_is, t_oos = run_mode_v2(tf)
    all_rows.extend(rows)
    trades_by_mode["%s_only_IS" % tf] = t_is
    trades_by_mode["%s_only_OOS" % tf] = t_oos

dual_tf_summary = pd.DataFrame(all_rows)
dual_tf_summary["scenario_order"] = dual_tf_summary["scenario"].map({"IS": 0, "OOS": 1})
dual_tf_summary = dual_tf_summary.sort_values(["timeframe_mode", "scenario_order"]).drop(columns=["scenario_order"])

print("\nDual timeframe summary:")
display(dual_tf_summary)
Running M15 validation with threshold=0.15
Running M5 validation with threshold=0.15

Dual timeframe summary:
timeframe_mode scenario xgb_threshold score win_rate profit_factor max_drawdown_pct n_trades ending_balance_cents net_return_pct relative_score_drop_is_to_oos split_time exec_rows
0 M15_only IS 0.15 5.342374 0.544021 2.542452 23.193496 761 22749.957737 1416.663849 0.739842 2025-05-05 02:45:00 92880
1 M15_only OOS 0.15 1.389863 0.476923 7.071726 11.386829 65 14502.293382 866.819559 0.739842 2025-05-05 02:45:00 23221
2 M5_only IS 0.15 9.423663 0.585882 2.800003 6.412390 2125 44104.640315 2840.309354 0.540412 2025-05-05 03:00:00 278056
3 M5_only OOS 0.15 4.331005 0.601202 3.205775 24.391260 499 36401.894862 2326.792991 0.540412 2025-05-05 03:00:00 69515
In [17]:
# -----------------------------
# Phase 9: Diagnostic Reports per Timeframe
# Each timeframe reports independently -- no cross-TF coupling.
# -----------------------------

for tf in TIMEFRAMES:
    p = pipeline[tf]
    met_is = p.metrics_is or {}
    met_oos = p.metrics_oos or {}
    plateau = p.plateau or {}

    print("=" * 60)
    print("TIMEFRAME: %s" % tf)
    print("=" * 60)
    if p.train_df is not None:
        print("  Training Samples:        %d" % len(p.train_df))
    else:
        print("  Training Samples:        N/A")
    if p.oos_df is not None:
        print("  OOS Samples:             %d" % len(p.oos_df))
    else:
        print("  OOS Samples:             N/A")
    if p.split_time is not None:
        print("  Split Time:               %s" % p.split_time)
    else:
        print("  Split Time:               N/A")
    if p.train_feat is not None:
        print("  Feature Count (HMM):      %d" % len(hmm_feature_columns(p.train_feat)))
    else:
        print("  Feature Count (HMM):      N/A")
    if p.model is not None:
        print("  HMM States:              %d" % p.model.n_components)
    else:
        print("  HMM States:              N/A")
    if p.threshold is not None:
        print("  Probability Threshold:    %s" % p.threshold)
    else:
        print("  Probability Threshold:    N/A")
    print("  IS Trades:               %s" % met_is.get("n_trades", "N/A"))
    print("  OOS Trades:              %s" % met_oos.get("n_trades", "N/A"))
    if met_is.get('win_rate') is not None:
        print("  IS Win Rate:             %.2f%%" % (met_is.get("win_rate", 0) * 100))
    else:
        print("  IS Win Rate:             N/A")
    if met_oos.get('win_rate') is not None:
        print("  OOS Win Rate:            %.2f%%" % (met_oos.get("win_rate", 0) * 100))
    else:
        print("  OOS Win Rate:            N/A")
    if met_is.get('profit_factor') is not None:
        print("  IS Profit Factor:        %.2f" % met_is.get("profit_factor", 0))
    else:
        print("  IS Profit Factor:        N/A")
    if met_oos.get('profit_factor') is not None:
        print("  OOS Profit Factor:       %.2f" % met_oos.get("profit_factor", 0))
    else:
        print("  OOS Profit Factor:       N/A")
    if met_is.get('max_drawdown_pct') is not None:
        print("  IS Max Drawdown:         %.2f%%" % met_is.get("max_drawdown_pct", 0))
    else:
        print("  IS Max Drawdown:         N/A")
    if met_oos.get('max_drawdown_pct') is not None:
        print("  OOS Max Drawdown:        %.2f%%" % met_oos.get("max_drawdown_pct", 0))
    else:
        print("  OOS Max Drawdown:        N/A")
    print("  Plateau Width:           %s" % plateau.get("plateau_width_local", "N/A"))
    print("  Plateau Score:           %s" % plateau.get("selection_score", "N/A"))
    if met_is:
        print("  IS Score (Sharpe):       %.4f" % score_metrics(met_is))
    else:
        print("  IS Score (Sharpe):       N/A")
    if met_oos:
        print("  OOS Score (Sharpe):      %.4f" % score_metrics(met_oos))
    else:
        print("  OOS Score (Sharpe):      N/A")
    if met_is and met_oos:
        s_is = score_metrics(met_is)
        s_oos = score_metrics(met_oos)
        drop = (s_is - s_oos) / (abs(s_is) + 1e-9)
        print("  Relative Score Drop:      %.2f%%" % (drop * 100), "  <- WARNING: >30%%" if drop > 0.30 else "")
    print()

print("=" * 60)
print("DIAGNOSTICS COMPLETE -- Both timeframes reported independently.")
print("=" * 60)
============================================================
TIMEFRAME: M15
============================================================
  Training Samples:        92881
  OOS Samples:             23220
  Split Time:               2025-05-05 03:00:00
  Feature Count (HMM):      16
  HMM States:              3
  Probability Threshold:    0.15
  IS Trades:               761
  OOS Trades:              65
  IS Win Rate:             54.40%
  OOS Win Rate:            47.69%
  IS Profit Factor:        2.54
  OOS Profit Factor:       7.07
  IS Max Drawdown:         23.19%
  OOS Max Drawdown:        11.39%
  Plateau Width:           11
  Plateau Score:           19.731341691790963
  IS Score (Sharpe):       5.3424
  OOS Score (Sharpe):      1.3899
  Relative Score Drop:      73.98%   <- WARNING: >30%%

============================================================
TIMEFRAME: M5
============================================================
  Training Samples:        278056
  OOS Samples:             69515
  Split Time:               2025-05-05 03:00:00
  Feature Count (HMM):      16
  HMM States:              3
  Probability Threshold:    0.15
  IS Trades:               2125
  OOS Trades:              499
  IS Win Rate:             58.59%
  OOS Win Rate:            60.12%
  IS Profit Factor:        2.80
  OOS Profit Factor:       3.21
  IS Max Drawdown:         6.41%
  OOS Max Drawdown:        24.39%
  Plateau Width:           11
  Plateau Score:           25.02271957435849
  IS Score (Sharpe):       9.4237
  OOS Score (Sharpe):      4.3310
  Relative Score Drop:      54.04%   <- WARNING: >30%%

============================================================
DIAGNOSTICS COMPLETE -- Both timeframes reported independently.
============================================================
In [18]:
# -----------------------------
# Optional live risk circuit breaker prototype
# -----------------------------

from scipy.stats import entropy

class ProductionRiskCircuitBreaker:
    def __init__(self, historical_mean_sharpe, historical_min_sharpe, max_allowed_slippage_pips=2.5):
        self.hist_mean_sharpe = float(historical_mean_sharpe)
        self.hist_min_sharpe = float(historical_min_sharpe)
        self.max_slippage = float(max_allowed_slippage_pips)

        self.trade_returns = []
        self.trade_slippages = []
        self.hmm_state_probabilities = []

    def log_executed_trade(self, return_pct, slippage_pips, hmm_proba_vector):
        self.trade_returns.append(float(return_pct))
        self.trade_slippages.append(float(slippage_pips))
        self.hmm_state_probabilities.append(np.array(hmm_proba_vector, dtype=float))

    def calculate_rolling_sharpe(self, window=30):
        if len(self.trade_returns) < window:
            return self.hist_mean_sharpe
        r = np.array(self.trade_returns[-window:], dtype=float)
        return float((r.mean() / (r.std() + 1e-6)) * np.sqrt(252.0))

    def check_regime_drift(self, window=12):
        if len(self.hmm_state_probabilities) < window:
            return 0.0
        recent = self.hmm_state_probabilities[-window:]
        return float(np.mean([entropy(np.clip(p, 1e-12, 1.0)) for p in recent]))

    def evaluate_system_health(self):
        if len(self.trade_returns) < 15:
            return "GREEN", "Initialization phase"

        live_sharpe = self.calculate_rolling_sharpe(window=30)
        avg_slippage = float(np.mean(self.trade_slippages[-15:]))
        avg_entropy = self.check_regime_drift(window=12)

        if avg_slippage > self.max_slippage:
            return "RED", f"HALT: slippage {avg_slippage:.2f} exceeds limit"

        if live_sharpe < self.hist_min_sharpe:
            return "RED", f"HALT: live sharpe {live_sharpe:.2f} below minimum"

        if live_sharpe < (self.hist_mean_sharpe * 0.5) or avg_entropy > 1.2:
            return "YELLOW", f"WARN: decay or high entropy {avg_entropy:.2f}"

        return "GREEN", f"Healthy. live sharpe={live_sharpe:.2f}"
In [19]:
# ============================================================
# DIAGNOSTIC INSTRUMENTATION LAYER — Setup
# ============================================================

from dataclasses import dataclass, field
from typing import Dict, List, Any, Tuple
import numpy as np
import pandas as pd
import warnings
import math

warnings.filterwarnings("ignore")

# Additional sklearn imports for calibration diagnostics
try:
    from sklearn.calibration import calibration_curve
    from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score, precision_recall_curve, average_precision_score
    CALIBRATION_OK = True
except Exception as e:
    CALIBRATION_OK = False
    print("WARNING: sklearn calibration metrics not available:", e)

# --- Central diagnostic storage ---
diagnostics = {
    "M15": {},
    "M5": {},
}
candidate_reports = {
    "M15": {},
    "M5": {},
}

@dataclass
class PipelineFunnel:
    """Tracks observation counts at every processing stage."""
    raw_bars: int = 0
    feature_rows: int = 0
    after_nan_removal: int = 0
    after_tbm: int = 0
    trend_pullback_signals: int = 0
    after_session_filter: int = 0
    after_regime_filter: int = 0
    after_probability_filter: int = 0
    executed_trades: int = 0
    winning_trades: int = 0
    losing_trades: int = 0

@dataclass
class FeatureLossAudit:
    """Tracks how many rows each feature engineering step removes."""
    raw: int = 0
    steps: List[Tuple[str, int]] = field(default_factory=list)
    final: int = 0

@dataclass
class RejectionBreakdown:
    """Tracks why candidate trades were rejected."""
    session_filter: int = 0
    regime_filter: int = 0
    probability_filter: int = 0
    atr_filter: int = 0
    spread_filter: int = 0
    risk_filter: int = 0
    duplicate_position: int = 0
    max_exposure: int = 0
    other: int = 0

print("Diagnostic instrumentation layer loaded.")
Diagnostic instrumentation layer loaded.
In [20]:
# ============================================================
# Phase 8: Feature Loss Audit + Phase 1 Pipeline Trace
# ============================================================

def _count_after_dropna(df, cols):
    """Return len of df[cols] after dropna."""
    subset = [c for c in cols if c in df.columns]
    return len(df[subset].dropna())

def build_features_with_trace(exec_panel: pd.DataFrame, exec_tf: str) -> tuple:
    """Replica of build_features that returns (df, trace, audit)."""
    exec_tf = exec_tf.upper()
    exec_df = exec_panel.copy()
    audit = FeatureLossAudit()
    audit.raw = len(exec_df)

    trace = {"raw": len(exec_df)}
    cols_so_far = ["Open", "High", "Low", "Close"]

    exec_df["log_return"] = np.log(exec_df["Close"] / exec_df["Close"].shift(1))
    cols_so_far.append("log_return")
    audit.steps.append(("log_return", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["xag_log_return"] = np.log(exec_df["XAG_Close"] / exec_df["XAG_Close"].shift(1))
    cols_so_far.append("xag_log_return")
    audit.steps.append(("xag_log_return", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["xti_log_return"] = np.log(exec_df["XTI_Close"] / exec_df["XTI_Close"].shift(1))
    cols_so_far.append("xti_log_return")
    audit.steps.append(("xti_log_return", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["gold_silver_ratio"] = exec_df["Close"] / exec_df["XAG_Close"]
    exec_df["gold_oil_ratio"] = exec_df["Close"] / exec_df["XTI_Close"]
    cols_so_far.extend(["gold_silver_ratio", "gold_oil_ratio"])
    audit.steps.append(("gold_ratios", _count_after_dropna(exec_df, cols_so_far)))

    rw = 64
    gs_m = exec_df["gold_silver_ratio"].rolling(rw, min_periods=rw).mean()
    gs_s = exec_df["gold_silver_ratio"].rolling(rw, min_periods=rw).std().replace(0, np.nan)
    go_m = exec_df["gold_oil_ratio"].rolling(rw, min_periods=rw).mean()
    go_s = exec_df["gold_oil_ratio"].rolling(rw, min_periods=rw).std().replace(0, np.nan)

    exec_df["gold_silver_ratio_z"] = ((exec_df["gold_silver_ratio"] - gs_m) / gs_s).replace([np.inf, -np.inf], np.nan).fillna(0.0)
    exec_df["gold_oil_ratio_z"] = ((exec_df["gold_oil_ratio"] - go_m) / go_s).replace([np.inf, -np.inf], np.nan).fillna(0.0)
    cols_so_far.extend(["gold_silver_ratio_z", "gold_oil_ratio_z"])
    audit.steps.append(("gold_ratio_z", _count_after_dropna(exec_df, cols_so_far)))

    tr = true_range(exec_df["High"], exec_df["Low"], exec_df["Close"])
    exec_df["atr_20"] = tr.rolling(20, min_periods=20).mean()
    exec_df["atr_normalized"] = exec_df["atr_20"] / exec_df["Close"]
    exec_df["volatility_20"] = exec_df["log_return"].rolling(20, min_periods=20).std()
    exec_df["synth_vix_zscore"] = synth_vix_zscore(exec_df["High"], exec_df["Low"], exec_df["Close"], period=22)
    cols_so_far.extend(["atr_20", "atr_normalized", "volatility_20", "synth_vix_zscore"])
    audit.steps.append(("volatility_cluster", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["hour"] = exec_df.index.hour
    exec_df["hour_sin"] = np.sin(2 * np.pi * exec_df["hour"] / 24.0)
    exec_df["hour_cos"] = np.cos(2 * np.pi * exec_df["hour"] / 24.0)
    cols_so_far.extend(["hour_sin", "hour_cos"])
    audit.steps.append(("temporal", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["rsi5"] = rsi(exec_df["Close"], period=5)
    exec_df["atr14"] = atr(exec_df["High"], exec_df["Low"], exec_df["Close"], period=14)
    exec_df["atr100"] = atr(exec_df["High"], exec_df["Low"], exec_df["Close"], period=100)
    exec_df["atr_expansion"] = exec_df["atr14"] / exec_df["atr100"].replace(0.0, np.nan)
    cols_so_far.extend(["rsi5", "atr14", "atr100", "atr_expansion"])
    audit.steps.append(("oscillators_atr", _count_after_dropna(exec_df, cols_so_far)))

    exec_df["macro_ema50"] = ema(exec_df["Close"], period=50)
    exec_df["macro_ema200"] = ema(exec_df["Close"], period=200)
    exec_df["macro_adx14"] = adx(exec_df["High"], exec_df["Low"], exec_df["Close"], period=14)
    cols_so_far.extend(["macro_ema50", "macro_ema200", "macro_adx14"])
    audit.steps.append(("macro_trend", _count_after_dropna(exec_df, cols_so_far)))

    merged = add_session_features(exec_df)
    merged["spread"] = SPREAD_CAP_POINTS
    cols_so_far.extend(["session_mask_none", "session_mask_london", "session_mask_ny", "session_mask_london_ny", "spread"])
    audit.steps.append(("session_features", _count_after_dropna(merged, cols_so_far)))

    is_trend = (merged["macro_adx14"] > 15.0) & (merged["atr_expansion"] < 1.3)
    is_shock = merged["atr_expansion"] >= 1.3
    merged["regime_str"] = np.where(is_shock, "SHOCK", np.where(is_trend, "TREND", "MR"))
    merged["regime_code"] = merged["regime_str"].map({"TREND": 1, "SHOCK": 2, "MR": 3})
    cols_so_far.extend(["regime_str", "regime_code"])
    audit.steps.append(("regime_assignment", _count_after_dropna(merged, cols_so_far)))

    keep = [
        "Open", "High", "Low", "Close",
        "log_return", "xag_log_return", "xti_log_return",
        "gold_silver_ratio_z", "gold_oil_ratio_z",
        "atr_20", "atr_normalized", "volatility_20",
        "synth_vix_zscore", "hour_sin", "hour_cos",
        "rsi5", "atr14", "atr100", "atr_expansion",
        "macro_ema50", "macro_ema200", "macro_adx14",
        "spread", "regime_str", "regime_code",
        "session_mask_none", "session_mask_london", "session_mask_ny", "session_mask_london_ny",
    ]

    before_dropna = len(merged)
    merged = merged[keep].dropna().copy()
    after_dropna = len(merged)
    audit.final = after_dropna

    result = triple_barrier(merged, exec_tf)
    trace["after_dropna"] = after_dropna
    trace["after_tbm"] = len(result)

    return result, trace, audit

print("build_features_with_trace ready.")
build_features_with_trace ready.
In [21]:
# ============================================================
# Phase 9: Signal & Rejection Tracking (Pre-Backtest)
# ============================================================

def generate_signals_diagnostic(df: pd.DataFrame, params: dict) -> tuple:
    """Generate signals and return both pre-session and post-session arrays."""
    adx_threshold = float(params.get("adx_threshold", 15.0))
    pullback_rsi = float(params.get("pullback_rsi", 40.0))
    confirmation_bars = int(params.get("confirmation_bars", 1))
    session_filter = params.get("session_filter", None)
    session_col = session_col_from_value(session_filter)

    trend_up_raw = (df["macro_ema50"] > df["macro_ema200"]) & (df["macro_adx14"] > adx_threshold)
    trend_dn_raw = (df["macro_ema50"] < df["macro_ema200"]) & (df["macro_adx14"] > adx_threshold)

    if confirmation_bars > 1:
        trend_up = trend_up_raw.rolling(confirmation_bars, min_periods=confirmation_bars).sum().eq(confirmation_bars)
        trend_dn = trend_dn_raw.rolling(confirmation_bars, min_periods=confirmation_bars).sum().eq(confirmation_bars)
    else:
        trend_up, trend_dn = trend_up_raw, trend_dn_raw

    # Pre-session: trend + pullback only
    long_pre = trend_up & (df["rsi5"] < pullback_rsi)
    short_pre = trend_dn & (df["rsi5"] > (100.0 - pullback_rsi))
    sig_pre = pd.Series(0, index=df.index, dtype=np.int8)
    sig_pre.loc[long_pre.fillna(False)] = 1
    sig_pre.loc[short_pre.fillna(False)] = -1

    # Post-session: add session filter
    long_post = long_pre & df[session_col].astype(bool)
    short_post = short_pre & df[session_col].astype(bool)
    sig_post = pd.Series(0, index=df.index, dtype=np.int8)
    sig_post.loc[long_post.fillna(False)] = 1
    sig_post.loc[short_post.fillna(False)] = -1

    return sig_pre, sig_post

def diagnostic_signal_trace(df: pd.DataFrame, ml_probs: np.ndarray, base_params: dict, xgb_threshold: float) -> dict:
    """Trace signal counts through every filtering stage."""
    sig_pre, sig_post = generate_signals_diagnostic(df, base_params)

    # Regime filter (TREND only)
    trend_mask = df["regime_code"].to_numpy() == 1
    regime_filtered = sig_post.where(trend_mask, 0)

    raw_arr = sig_pre.to_numpy(dtype=np.int8)
    post_arr = sig_post.to_numpy(dtype=np.int8)
    regime_arr = regime_filtered.to_numpy(dtype=np.int8)

    prob_down = ml_probs[:, 0]
    prob_up = ml_probs[:, 2]
    filtered_arr = regime_arr.copy()
    filtered_arr[(regime_arr == 1) & (prob_up < xgb_threshold)] = 0
    filtered_arr[(regime_arr == -1) & (prob_down < xgb_threshold)] = 0

    return {
        "raw_signals": int(np.sum(raw_arr != 0)),
        "after_session_filter": int(np.sum(post_arr != 0)),
        "after_regime_filter": int(np.sum(regime_arr != 0)),
        "after_probability_filter": int(np.sum(filtered_arr != 0)),
        "signal_array": filtered_arr,
    }

print("generate_signals_diagnostic and diagnostic_signal_trace ready.")
generate_signals_diagnostic and diagnostic_signal_trace ready.
In [22]:
# ============================================================
# Phase 9 (continued): Execution-Level Rejection Tracking
# ============================================================

def diagnostic_backtest_trace(timeframe: str, df: pd.DataFrame, ml_probs: np.ndarray, 
                            base_params: dict, xgb_threshold: float) -> dict:
    """Run full backtest and trace execution-level rejections."""

    # 1. Signal trace (pre-backtest)
    sig_trace = diagnostic_signal_trace(df, ml_probs, base_params, xgb_threshold)
    filtered_arr = sig_trace["signal_array"]

    # 2. Run actual backtest
    trades_df, met = run_ml_filtered_backtest(timeframe, df, ml_probs, base_params, xgb_threshold)

    n_executed = len(trades_df) if not trades_df.empty else 0
    n_win = (trades_df["pnl_cents"] > 0).sum() if not trades_df.empty else 0
    n_loss = (trades_df["pnl_cents"] <= 0).sum() if not trades_df.empty else 0

    # 3. Execution-level rejection analysis
    rej = RejectionBreakdown()
    prob_passed = sig_trace["after_probability_filter"]
    execution_rejected = prob_passed - n_executed

    if execution_rejected > 0:
        # Analyse each signal bar that did not result in a trade
        sig = filtered_arr
        high = df["High"].to_numpy(dtype=np.float64)
        low = df["Low"].to_numpy(dtype=np.float64)
        close = df["Close"].to_numpy(dtype=np.float64)
        spread = df["spread"].fillna(40.0).to_numpy(dtype=np.float64)
        atr14 = df["atr14"].to_numpy(dtype=np.float64)
        regime_code = df["regime_code"].to_numpy(dtype=np.int32)

        # We need to know which bars had entries.
        # Re-run the backtest internals to get entry indices.
        idx = df.index
        time_minutes = (idx.view("int64") // 60000000000).astype(np.int64)
        is_m5 = 1 if timeframe == "M5" else 0
        exit_model_map = {"fixed_tp": 0, "mr_exit": 1, "fixed_tp_plus_mr": 2, "partial_tp_plus_mr": 3, "partial_tp_mr_time_stop": 4}
        exit_mode_code = int(exit_model_map.get(base_params.get("exit_model", "fixed_tp"), 0))

        sl_dist = float(base_params.get("atr_stop", base_params.get("sl_atr", base_params.get("atr_mult_sl", base_params.get("stop_loss_multiplier", 2.0)))))
        tp_dist = float(base_params.get("atr_target", base_params.get("tp_atr", base_params.get("atr_mult_tp", base_params.get("take_profit_multiplier", 2.0)))))
        leg_a_tp = float(base_params.get("leg_a_atr_target", base_params.get("leg_a_tp", 1.0)))

        (trade_entry_idx, trade_exit_idx, trade_leg, trade_side, trade_entry_px,
         trade_exit_px, trade_move_pips, trade_pnl_cents, trade_reason,
         trade_entry_regime, trade_count, eq, eq_count, ending_balance) = _run_backtest_numba(
            sig, high, low, close, spread, atr14, regime_code, time_minutes,
            sl_dist, tp_dist, leg_a_tp, exit_mode_code, 
            float(base_params.get("time_stop_minutes", -1.0)), float(base_params.get("trail_mult", 0.0)),
            float(INITIAL_BALANCE_CENTS), 0.10, 100.0, 0.30, 40.0, 0.0, int(is_m5)
        )

        entry_bars = set(trade_entry_idx[:trade_count]) if trade_count > 0 else set()

        # Build a simple position-active tracker
        n = len(sig)
        position_active = np.zeros(n, dtype=bool)
        for t in range(trade_count):
            e = int(trade_entry_idx[t])
            x = int(trade_exit_idx[t])
            if e < n and x < n:
                position_active[e:x+1] = True

        # Balance tracker (interpolate from equity curve at trade boundaries)
        balance_at = np.full(n, float(INITIAL_BALANCE_CENTS), dtype=float)
        for t in range(trade_count):
            x = int(trade_exit_idx[t])
            if x < n:
                balance_at[x:] = float(eq[min(t+1, eq_count-1)])

        for i in range(n):
            if sig[i] == 0:
                continue
            if i in entry_bars:
                continue

            # Signal passed probability filter but did not enter
            if balance_at[i] <= 1000.0:
                rej.risk_filter += 1
            elif not np.isfinite(atr14[i]) or atr14[i] <= 0.0:
                rej.atr_filter += 1
            elif (spread[i] / 10.0 * 0.10) > (0.8 * atr14[i]):
                rej.spread_filter += 1
            elif position_active[i]:
                # Already in a position (duplicate or max exposure)
                rej.duplicate_position += 1
            else:
                rej.other += 1

    return {
        "raw_signals": sig_trace["raw_signals"],
        "after_session_filter": sig_trace["after_session_filter"],
        "after_regime_filter": sig_trace["after_regime_filter"],
        "after_probability_filter": sig_trace["after_probability_filter"],
        "executed_trades": n_executed,
        "winning_trades": int(n_win),
        "losing_trades": int(n_loss),
        "rejection_breakdown": rej,
    }, trades_df, met

print("diagnostic_backtest_trace ready.")
diagnostic_backtest_trace ready.
In [23]:
# ============================================================
# Phase 4: HMM Diagnostics
# ============================================================

def hmm_diagnostics(model, df: pd.DataFrame) -> dict:
    """Extract HMM state diagnostics from a trained model and dataset."""
    if model is None or model.hmm is None:
        return {}
    
    X_hmm = df[model.hmm_features_].to_numpy(dtype=float)
    X_hmm = model.hmm_scaler.transform(X_hmm)
    states = model.hmm.predict(X_hmm)
    
    n_samples = len(states)
    occupancy = {}
    state_stats = {}
    
    for s in range(model.n_components):
        mask = states == s
        count = int(mask.sum())
        pct = count / n_samples * 100 if n_samples > 0 else 0.0
        occupancy[s] = {"count": count, "pct": pct}
        
        avg_ret = float(df.loc[mask, "log_return"].mean()) if "log_return" in df.columns else 0.0
        avg_atr = float(df.loc[mask, "atr14"].mean()) if "atr14" in df.columns else 0.0
        avg_adx = float(df.loc[mask, "macro_adx14"].mean()) if "macro_adx14" in df.columns else 0.0
        
        state_stats[s] = {
            "samples": count,
            "avg_return": avg_ret,
            "avg_atr": avg_atr,
            "avg_adx": avg_adx,
        }
    
    # Transition matrix
    trans = model.hmm.transmat_ if hasattr(model.hmm, "transmat_") else None
    
    return {
        "occupancy": occupancy,
        "state_stats": state_stats,
        "transition_matrix": trans.tolist() if trans is not None else None,
    }

print("hmm_diagnostics ready.")
hmm_diagnostics ready.
In [24]:
# ============================================================
# Phase 5: XGBoost Probability Distribution
# Phase 6: Threshold Sensitivity
# ============================================================

def probability_distribution(model, df: pd.DataFrame) -> dict:
    """Compute probability distribution statistics."""
    if model is None:
        return {}
    probs = model.predict_proba_raw(df)
    p_down = probs[:, 0]
    p_up = probs[:, 2]
    p_extreme = np.maximum(p_down, p_up)
    
    def stats(arr):
        a = np.asarray(arr)
        return {
            "min": float(np.min(a)),
            "max": float(np.max(a)),
            "mean": float(np.mean(a)),
            "median": float(np.median(a)),
            "std": float(np.std(a)),
            "p10": float(np.percentile(a, 10)),
            "p25": float(np.percentile(a, 25)),
            "p50": float(np.percentile(a, 50)),
            "p75": float(np.percentile(a, 75)),
            "p90": float(np.percentile(a, 90)),
        }
    
    return {
        "prob_down": stats(p_down),
        "prob_up": stats(p_up),
        "prob_extreme": stats(p_extreme),
    }

def threshold_sensitivity(model, df: pd.DataFrame, tf: str, base_params: dict, 
                           thresholds: list = None) -> pd.DataFrame:
    """Evaluate backtest at multiple thresholds (measurement, not optimisation)."""
    if thresholds is None:
        thresholds = [0.45, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75]
    
    rows = []
    probs = model.predict_proba_raw(df) if model else None
    
    for thr in thresholds:
        if probs is None:
            rows.append({"threshold": thr, "trades": 0, "win_rate": 0.0, "profit_factor": 0.0})
            continue
        trades, met = run_ml_filtered_backtest(tf, df, probs, base_params, float(thr))
        n_trades = len(trades) if not trades.empty else 0
        wr = float(met.get("win_rate", 0.0)) if n_trades > 0 else 0.0
        pf = float(met.get("profit_factor", 0.0)) if n_trades > 0 else 0.0
        rows.append({
            "threshold": thr,
            "trades": n_trades,
            "win_rate": wr,
            "profit_factor": pf,
        })
    
    return pd.DataFrame(rows)

print("probability_distribution and threshold_sensitivity ready.")
probability_distribution and threshold_sensitivity ready.
In [25]:
# ============================================================
# Phase 10: Classifier Calibration
# ============================================================

def calibration_metrics(model, df: pd.DataFrame) -> dict:
    """Compute calibration and discrimination metrics."""
    if not CALIBRATION_OK or model is None:
        return {}
    
    probs = model.predict_proba_raw(df)
    y_true = df["tb_label"].to_numpy(dtype=int)
    
    # Map labels to classes: -1->0, 0->1, 1->2
    y_cls = np.where(y_true == -1, 0, np.where(y_true == 1, 2, 1))
    
    # Filter out neutral (class 1) for binary calibration on extreme classes
    mask_extreme = y_true != 0
    y_bin = np.where(y_true[mask_extreme] == 1, 1, 0)  # 1 = up, 0 = down
    p_up = probs[mask_extreme, 2]
    p_down = probs[mask_extreme, 0]
    p_pos = p_up / (p_up + p_down + 1e-12)
    
    # Brier score (on up-vs-down binary task)
    brier = brier_score_loss(y_bin, p_pos) if len(y_bin) > 0 else np.nan
    
    # Log loss (multiclass)
    ll = log_loss(y_cls, probs, labels=[0,1,2]) if len(set(y_cls)) > 1 else np.nan
    
    # ROC AUC (up-vs-down, one-vs-rest)
    try:
        roc_auc = roc_auc_score(y_bin, p_pos) if len(set(y_bin)) > 1 else np.nan
    except Exception:
        roc_auc = np.nan
    
    # Average precision
    try:
        avg_prec = average_precision_score(y_bin, p_pos) if len(set(y_bin)) > 1 else np.nan
    except Exception:
        avg_prec = np.nan
    
    # Calibration curve data
    prob_true, prob_pred = calibration_curve(y_bin, p_pos, n_bins=10) if len(y_bin) > 0 else (np.array([]), np.array([]))
    
    return {
        "brier_score": float(brier),
        "log_loss": float(ll),
        "roc_auc": float(roc_auc),
        "average_precision": float(avg_prec),
        "calibration_prob_true": prob_true.tolist(),
        "calibration_prob_pred": prob_pred.tolist(),
    }

print("calibration_metrics ready.")
calibration_metrics ready.
In [26]:
# ============================================================
# Phase 7: Strategy Tester vs Explorer Comparison
# ============================================================

def compare_strategy_tester_signals(tf: str, explorer_signals: int) -> dict:
    """Compare signal counts between Strategy Tester and Explorer."""
    st_path = Path("reports/strategy_winners_for_explorer.csv")
    st_signals = None
    st_trades = None
    if st_path.exists():
        try:
            st_df = pd.read_csv(st_path)
            row = st_df[st_df["timeframe"] == tf]
            if not row.empty:
                st_trades = int(row.iloc[0]["trade_count"])
                # Strategy tester trade_count is not signal count, but it's the closest proxy
                # We use it as a reference for "how many trades the ST produced"
        except Exception as e:
            print(f"  Could not load ST data for {tf}: {e}")
    
    return {
        "explorer_signals": explorer_signals,
        "strategy_tester_trades": st_trades,
        "difference": (explorer_signals - st_trades) if st_trades is not None else None,
        "pct_lost": ((explorer_signals - st_trades) / st_trades * 100) if st_trades and st_trades > 0 else None,
    }

print("compare_strategy_tester_signals ready.")
compare_strategy_tester_signals ready.
In [27]:
# ============================================================
# MAIN DIAGNOSTIC RUNNER — Phases 1-12
# ============================================================

all_diagnostics = {}

for tf in TIMEFRAMES:
    print(f"\n{'='*60}")
    print(f"RUNNING DIAGNOSTICS FOR {tf}")
    print(f"{'='*60}")
    
    p = pipeline[tf]
    base_params = dict(ML_TARGET_PARAMS[tf]["parameters"])
    base_params["exit_model"] = ML_TARGET_PARAMS[tf]["exit_model"]
    
    train_df = p.train_df
    oos_df = p.oos_df
    model = p.model
    thr = float(p.threshold) if p.threshold is not None else 0.15
    
    # --- Phase 1: Pipeline Funnel (IS) ---
    funnel = PipelineFunnel()
    funnel.raw_bars = len(p.raw_all)
    
    feat_is, trace_is, audit_is = build_features_with_trace(train_df, tf)
    funnel.feature_rows = trace_is["raw"]
    funnel.after_nan_removal = audit_is.final
    funnel.after_tbm = len(feat_is)
    
    if model is not None:
        probs_is = model.predict_proba_raw(feat_is)
        diag_is, trades_is, met_is = diagnostic_backtest_trace(tf, feat_is, probs_is, base_params, thr)
    else:
        diag_is = {"raw_signals":0, "after_session_filter":0, "after_regime_filter":0, "after_probability_filter":0, "executed_trades":0, "winning_trades":0, "losing_trades":0, "rejection_breakdown": RejectionBreakdown()}
        trades_is = pd.DataFrame()
        met_is = {}
    
    funnel.trend_pullback_signals = diag_is["raw_signals"]
    funnel.after_session_filter = diag_is["after_session_filter"]
    funnel.after_regime_filter = diag_is["after_regime_filter"]
    funnel.after_probability_filter = diag_is["after_probability_filter"]
    funnel.executed_trades = diag_is["executed_trades"]
    funnel.winning_trades = diag_is["winning_trades"]
    funnel.losing_trades = diag_is["losing_trades"]
    
    # --- OOS trace ---
    feat_oos, trace_oos, audit_oos = build_features_with_trace(oos_df, tf)
    if model is not None:
        probs_oos = model.predict_proba_raw(feat_oos)
        diag_oos, trades_oos, met_oos = diagnostic_backtest_trace(tf, feat_oos, probs_oos, base_params, thr)
    else:
        diag_oos = {"raw_signals":0, "after_session_filter":0, "after_regime_filter":0, "after_probability_filter":0, "executed_trades":0, "winning_trades":0, "losing_trades":0, "rejection_breakdown": RejectionBreakdown()}
        trades_oos = pd.DataFrame()
        met_oos = {}
    
    # --- Phase 3: Label Distribution ---
    labels_is = feat_is["tb_label"].value_counts().to_dict()
    labels_oos = feat_oos["tb_label"].value_counts().to_dict()
    
    # --- Phase 4: HMM Diagnostics ---
    hmm_diag = hmm_diagnostics(model, feat_is) if model else {}
    
    # --- Phase 5: Probability Distribution ---
    prob_dist = probability_distribution(model, feat_is) if model else {}
    
    # --- Phase 6: Threshold Sensitivity (OOS) ---
    thresh_sens = threshold_sensitivity(model, feat_oos, tf, base_params) if model else pd.DataFrame()
    
    # --- Phase 7: Strategy Tester Comparison ---
    st_cmp = compare_strategy_tester_signals(tf, diag_is["raw_signals"])
    
    # --- Phase 8: Feature Loss Audit ---
    feature_audit = {"IS": audit_is, "OOS": audit_oos}
    
    # --- Phase 9: Rejection Breakdown ---
    rej_is = diag_is["rejection_breakdown"]
    rej_oos = diag_oos["rejection_breakdown"]
    
    # --- Phase 10: Calibration ---
    cal = calibration_metrics(model, feat_is) if model else {}
    
    all_diagnostics[tf] = {
        "funnel": funnel,
        "labels_is": labels_is,
        "labels_oos": labels_oos,
        "hmm": hmm_diag,
        "prob_dist": prob_dist,
        "thresh_sens": thresh_sens,
        "st_cmp": st_cmp,
        "feature_audit": feature_audit,
        "rej_is": rej_is,
        "rej_oos": rej_oos,
        "calibration": cal,
        "diag_is": diag_is,
        "diag_oos": diag_oos,
        "met_is": met_is,
        "met_oos": met_oos,
    }
    
    
    # Phase 2: Candidate Report
    candidate_reports[tf] = {
        "raw_bars": funnel.raw_bars,
        "trend_pullback_signals": funnel.trend_pullback_signals,
        "after_feature_engineering": funnel.after_nan_removal,
        "after_tbm": funnel.after_tbm,
        "after_label_drop": funnel.after_tbm,
        "after_probability_filter": funnel.after_probability_filter,
        "executed_trades": funnel.executed_trades,
    }
    print(f"Diagnostics for {tf} complete.")

print("\nAll diagnostics collected.")
============================================================
RUNNING DIAGNOSTICS FOR M15
============================================================
Diagnostics for M15 complete.

============================================================
RUNNING DIAGNOSTICS FOR M5
============================================================
Diagnostics for M5 complete.

All diagnostics collected.
In [28]:
# ============================================================
# Print All Diagnostic Reports
# ============================================================

def print_funnel(tf, funnel, scenario="IS"):
    print(f"\n{'='*60}")
    print(f"TIMEFRAME : {tf}  ({scenario})")
    print(f"{'='*60}")
    print(f"Raw Bars                    : {funnel.raw_bars}")
    print(f"Feature Rows                : {funnel.feature_rows}")
    print(f"Rows after NaN Removal      : {funnel.after_nan_removal}")
    print(f"Rows after TBM              : {funnel.after_tbm}")
    print(f"Trend Pullback Signals      : {funnel.trend_pullback_signals}")
    print(f"After Session Filter        : {funnel.after_session_filter}")
    print(f"After Regime Filter         : {funnel.after_regime_filter}")
    print(f"After Probability Filter    : {funnel.after_probability_filter}")
    print(f"Executed Trades             : {funnel.executed_trades}")
    print(f"Winning Trades              : {funnel.winning_trades}")
    print(f"Losing Trades               : {funnel.losing_trades}")

def print_labels(tf, labels, scenario="IS"):
    pos = int(labels.get(1, 0))
    neg = int(labels.get(-1, 0))
    neu = int(labels.get(0, 0))
    total = pos + neg + neu
    print(f"\n{'='*60}")
    print(f"TBM LABELS  {tf} {scenario}")
    print(f"{'='*60}")
    print(f"Positive       : {pos}")
    print(f"Negative       : {neg}")
    print(f"Neutral        : {neu}")
    print(f"Class Ratio    : {pos}:{neg}:{neu}")
    if total > 0:
        print(f"Positive %     : {pos/total*100:.1f}%")
        print(f"Negative %     : {neg/total*100:.1f}%")
        print(f"Neutral %      : {neu/total*100:.1f}%")

def print_hmm(tf, hmm_diag):
    if not hmm_diag:
        return
    print(f"\n{'='*60}")
    print(f"HMM DIAGNOSTICS  {tf}")
    print(f"{'='*60}")
    occ = hmm_diag.get("occupancy", {})
    stats = hmm_diag.get("state_stats", {})
    for s in sorted(occ.keys()):
        pct = occ[s]["pct"]
        print(f"\nHidden State {s}")
        print(f"  Samples          : {stats[s]['samples']}")
        print(f"  Average Return   : {stats[s]['avg_return']:.6f}")
        print(f"  Average ATR      : {stats[s]['avg_atr']:.4f}")
        print(f"  Average ADX      : {stats[s]['avg_adx']:.4f}")
        print(f"  State Occupancy %: {pct:.1f}%")
        if pct < 5:
            print("  WARNING: Sparse HMM State (<5%)")
    trans = hmm_diag.get("transition_matrix")
    if trans:
        print(f"\nTransition Probabilities:")
        for row in trans:
            print("  " + "  ".join([f"{v:.3f}" for v in row]))

def print_prob_dist(tf, prob_dist):
    if not prob_dist:
        return
    print(f"\n{'='*60}")
    print(f"XGBoost Probability Distribution  {tf}")
    print(f"{'='*60}")
    for key in ["prob_down", "prob_up", "prob_extreme"]:
        s = prob_dist.get(key, {})
        if not s:
            continue
        print(f"\n{key}:")
        print(f"  Min    : {s['min']:.4f}")
        print(f"  Max    : {s['max']:.4f}")
        print(f"  Mean   : {s['mean']:.4f}")
        print(f"  Median : {s['median']:.4f}")
        print(f"  Std    : {s['std']:.4f}")
        print(f"  P10    : {s['p10']:.4f}")
        print(f"  P25    : {s['p25']:.4f}")
        print(f"  P50    : {s['p50']:.4f}")
        print(f"  P75    : {s['p75']:.4f}")
        print(f"  P90    : {s['p90']:.4f}")

def print_thresh_sens(tf, thresh_sens):
    if thresh_sens.empty:
        return
    print(f"\n{'='*60}")
    print(f"THRESHOLD SENSITIVITY  {tf}")
    print(f"{'='*60}")
    print(thresh_sens.to_string(index=False))

def print_feature_audit(tf, audit):
    print(f"\n{'='*60}")
    print(f"FEATURE LOSS AUDIT  {tf}")
    print(f"{'='*60}")
    print(f"Raw     : {audit.raw}")
    prev = audit.raw
    for step_name, count in audit.steps:
        print(f"  {step_name:30s} : {count}")
        prev = count
    print(f"Final   : {audit.final}")
    if audit.raw > 0:
        print(f"Total Loss: {audit.raw - audit.final} rows ({(audit.raw - audit.final)/audit.raw*100:.2f}%)")

def print_rejections(tf, rej, scenario="IS"):
    print(f"\n{'='*60}")
    print(f"TRADE REJECTION REASONS  {tf} {scenario}")
    print(f"{'='*60}")
    print(f"Session Filter        : {rej.session_filter}")
    print(f"Regime Filter         : {rej.regime_filter}")
    print(f"Probability Filter    : {rej.probability_filter}")
    print(f"ATR Filter            : {rej.atr_filter}")
    print(f"Spread Filter         : {rej.spread_filter}")
    print(f"Risk Filter           : {rej.risk_filter}")
    print(f"Duplicate Position    : {rej.duplicate_position}")
    print(f"Max Exposure          : {rej.max_exposure}")
    print(f"Other                 : {rej.other}")

def print_calibration(tf, cal):
    if not cal:
        return
    print(f"\n{'='*60}")
    print(f"CLASSIFIER CALIBRATION  {tf}")
    print(f"{'='*60}")
    print(f"Brier Score        : {cal.get('brier_score', np.nan):.4f}")
    print(f"Log Loss           : {cal.get('log_loss', np.nan):.4f}")
    print(f"ROC AUC            : {cal.get('roc_auc', np.nan):.4f}")
    print(f"Average Precision  : {cal.get('average_precision', np.nan):.4f}")

def print_st_comparison(tf, cmp):
    print(f"\n{'='*60}")
    print(f"STRATEGY TESTER vs EXPLORER  {tf}")
    print(f"{'='*60}")
    st_trades = cmp.get("strategy_tester_trades")
    exp_signals = cmp.get("explorer_signals")
    if st_trades is not None and exp_signals is not None:
        diff = cmp.get("difference", 0)
        pct = cmp.get("pct_lost", 0)
        print(f"Strategy Tester Trades  : {st_trades}")
        print(f"Explorer Signals        : {exp_signals}")
        print(f"Difference              : {diff}")
        if pct is not None:
            print(f"Percentage Lost         : {pct:.1f}%")
            if abs(pct) > 20:
                print("WARNING: Signal Loss Detected (>20%)")
    else:
        print("Strategy Tester comparison data not available.")

# --- Print everything ---
for tf in TIMEFRAMES:
    d = all_diagnostics[tf]
    print_funnel(tf, d["funnel"], "IS")
    print_labels(tf, d["labels_is"], "IS")
    print_labels(tf, d["labels_oos"], "OOS")
    print_hmm(tf, d["hmm"])
    print_prob_dist(tf, d["prob_dist"])
    print_thresh_sens(tf, d["thresh_sens"])
    print_feature_audit(tf, d["feature_audit"]["IS"])
    print_rejections(tf, d["rej_is"], "IS")
    print_rejections(tf, d["rej_oos"], "OOS")
    print_calibration(tf, d["calibration"])
    print_st_comparison(tf, d["st_cmp"])
============================================================
TIMEFRAME : M15  (IS)
============================================================
Raw Bars                    : 116101
Feature Rows                : 92881
Rows after NaN Removal      : 92682
Rows after TBM              : 92682
Trend Pullback Signals      : 6725
After Session Filter        : 2585
After Regime Filter         : 1624
After Probability Filter    : 483
Executed Trades             : 761
Winning Trades              : 414
Losing Trades               : 347

============================================================
TBM LABELS  M15 IS
============================================================
Positive       : 20934
Negative       : 20825
Neutral        : 50923
Class Ratio    : 20934:20825:50923
Positive %     : 22.6%
Negative %     : 22.5%
Neutral %      : 54.9%

============================================================
TBM LABELS  M15 OOS
============================================================
Positive       : 4974
Negative       : 5373
Neutral        : 12674
Class Ratio    : 4974:5373:12674
Positive %     : 21.6%
Negative %     : 23.3%
Neutral %      : 55.1%

============================================================
HMM DIAGNOSTICS  M15
============================================================

Hidden State 0
  Samples          : 37926
  Average Return   : 0.000008
  Average ATR      : 2.4898
  Average ADX      : 26.0720
  State Occupancy %: 40.9%

Hidden State 1
  Samples          : 33786
  Average Return   : 0.000009
  Average ATR      : 1.5398
  Average ADX      : 22.1244
  State Occupancy %: 36.5%

Hidden State 2
  Samples          : 20970
  Average Return   : -0.000003
  Average ATR      : 4.3587
  Average ADX      : 29.4908
  State Occupancy %: 22.6%

Transition Probabilities:
  0.953  0.025  0.022
  0.026  0.972  0.001
  0.042  0.000  0.958

============================================================
XGBoost Probability Distribution  M15
============================================================

prob_down:
  Min    : 0.0000
  Max    : 0.9999
  Mean   : 0.2247
  Median : 0.0001
  Std    : 0.4173
  P10    : 0.0000
  P25    : 0.0000
  P50    : 0.0001
  P75    : 0.0001
  P90    : 0.9999

prob_up:
  Min    : 0.0000
  Max    : 0.9999
  Mean   : 0.2259
  Median : 0.0000
  Std    : 0.4181
  P10    : 0.0000
  P25    : 0.0000
  P50    : 0.0000
  P75    : 0.0001
  P90    : 0.9999

prob_extreme:
  Min    : 0.0000
  Max    : 0.9999
  Mean   : 0.4505
  Median : 0.0001
  Std    : 0.4975
  P10    : 0.0000
  P25    : 0.0000
  P50    : 0.0001
  P75    : 0.9999
  P90    : 0.9999

============================================================
THRESHOLD SENSITIVITY  M15
============================================================
 threshold  trades  win_rate  profit_factor
      0.45      65  0.476923       7.071726
      0.50      65  0.476923       7.071726
      0.55      65  0.476923       7.071726
      0.60      65  0.476923       7.071726
      0.65      65  0.476923       7.071726
      0.70      65  0.476923       7.071726
      0.75      65  0.476923       7.071726

============================================================
FEATURE LOSS AUDIT  M15
============================================================
Raw     : 92881
  log_return                     : 92880
  xag_log_return                 : 92880
  xti_log_return                 : 92880
  gold_ratios                    : 92880
  gold_ratio_z                   : 92880
  volatility_cluster             : 92861
  temporal                       : 92861
  oscillators_atr                : 92782
  macro_trend                    : 92682
  session_features               : 92682
  regime_assignment              : 92682
Final   : 92682
Total Loss: 199 rows (0.21%)

============================================================
TRADE REJECTION REASONS  M15 IS
============================================================
Session Filter        : 0
Regime Filter         : 0
Probability Filter    : 0
ATR Filter            : 0
Spread Filter         : 0
Risk Filter           : 0
Duplicate Position    : 0
Max Exposure          : 0
Other                 : 0

============================================================
TRADE REJECTION REASONS  M15 OOS
============================================================
Session Filter        : 0
Regime Filter         : 0
Probability Filter    : 0
ATR Filter            : 0
Spread Filter         : 0
Risk Filter           : 0
Duplicate Position    : 77
Max Exposure          : 0
Other                 : 0

============================================================
CLASSIFIER CALIBRATION  M15
============================================================
Brier Score        : 0.0000
Log Loss           : 0.0001
ROC AUC            : 1.0000
Average Precision  : 1.0000

============================================================
STRATEGY TESTER vs EXPLORER  M15
============================================================
Strategy Tester Trades  : 1974
Explorer Signals        : 6725
Difference              : 4751
Percentage Lost         : 240.7%
WARNING: Signal Loss Detected (>20%)

============================================================
TIMEFRAME : M5  (IS)
============================================================
Raw Bars                    : 347571
Feature Rows                : 278056
Rows after NaN Removal      : 277857
Rows after TBM              : 277857
Trend Pullback Signals      : 19296
After Session Filter        : 6686
After Regime Filter         : 4002
After Probability Filter    : 1786
Executed Trades             : 2125
Winning Trades              : 1245
Losing Trades               : 880

============================================================
TBM LABELS  M5 IS
============================================================
Positive       : 115685
Negative       : 114490
Neutral        : 47682
Class Ratio    : 115685:114490:47682
Positive %     : 41.6%
Negative %     : 41.2%
Neutral %      : 17.2%

============================================================
TBM LABELS  M5 OOS
============================================================
Positive       : 28375
Negative       : 29843
Neutral        : 11098
Class Ratio    : 28375:29843:11098
Positive %     : 40.9%
Negative %     : 43.1%
Neutral %      : 16.0%

============================================================
HMM DIAGNOSTICS  M5
============================================================

Hidden State 0
  Samples          : 116225
  Average Return   : 0.000003
  Average ATR      : 1.3640
  Average ADX      : 25.0220
  State Occupancy %: 41.8%

Hidden State 1
  Samples          : 98447
  Average Return   : 0.000001
  Average ATR      : 0.7728
  Average ADX      : 21.7227
  State Occupancy %: 35.4%

Hidden State 2
  Samples          : 63185
  Average Return   : 0.000001
  Average ATR      : 2.6559
  Average ADX      : 28.3494
  State Occupancy %: 22.7%

Transition Probabilities:
  0.969  0.017  0.013
  0.020  0.979  0.001
  0.026  0.000  0.974

============================================================
XGBoost Probability Distribution  M5
============================================================

prob_down:
  Min    : 0.0001
  Max    : 0.9999
  Mean   : 0.4120
  Median : 0.0001
  Std    : 0.4921
  P10    : 0.0001
  P25    : 0.0001
  P50    : 0.0001
  P75    : 0.9999
  P90    : 0.9999

prob_up:
  Min    : 0.0001
  Max    : 0.9999
  Mean   : 0.4163
  Median : 0.0001
  Std    : 0.4929
  P10    : 0.0001
  P25    : 0.0001
  P50    : 0.0001
  P75    : 0.9999
  P90    : 0.9999

prob_extreme:
  Min    : 0.0001
  Max    : 0.9999
  Mean   : 0.8283
  Median : 0.9999
  Std    : 0.3770
  P10    : 0.0001
  P25    : 0.9999
  P50    : 0.9999
  P75    : 0.9999
  P90    : 0.9999

============================================================
THRESHOLD SENSITIVITY  M5
============================================================
 threshold  trades  win_rate  profit_factor
      0.45     499  0.601202       3.205775
      0.50     499  0.601202       3.205775
      0.55     499  0.601202       3.205775
      0.60     499  0.601202       3.205775
      0.65     499  0.601202       3.205775
      0.70     499  0.601202       3.205775
      0.75     499  0.601202       3.205775

============================================================
FEATURE LOSS AUDIT  M5
============================================================
Raw     : 278056
  log_return                     : 278055
  xag_log_return                 : 278055
  xti_log_return                 : 278055
  gold_ratios                    : 278055
  gold_ratio_z                   : 278055
  volatility_cluster             : 278036
  temporal                       : 278036
  oscillators_atr                : 277957
  macro_trend                    : 277857
  session_features               : 277857
  regime_assignment              : 277857
Final   : 277857
Total Loss: 199 rows (0.07%)

============================================================
TRADE REJECTION REASONS  M5 IS
============================================================
Session Filter        : 0
Regime Filter         : 0
Probability Filter    : 0
ATR Filter            : 0
Spread Filter         : 0
Risk Filter           : 0
Duplicate Position    : 0
Max Exposure          : 0
Other                 : 0

============================================================
TRADE REJECTION REASONS  M5 OOS
============================================================
Session Filter        : 0
Regime Filter         : 0
Probability Filter    : 0
ATR Filter            : 0
Spread Filter         : 0
Risk Filter           : 0
Duplicate Position    : 244
Max Exposure          : 0
Other                 : 0

============================================================
CLASSIFIER CALIBRATION  M5
============================================================
Brier Score        : 0.0000
Log Loss           : 0.0001
ROC AUC            : 1.0000
Average Precision  : 1.0000

============================================================
STRATEGY TESTER vs EXPLORER  M5
============================================================
Strategy Tester Trades  : 4354
Explorer Signals        : 19296
Difference              : 14942
Percentage Lost         : 343.2%
WARNING: Signal Loss Detected (>20%)
In [29]:
# ============================================================
# Phase 11: Automatic Timeframe Comparison
# Phase 12: Root Cause Detection
# ============================================================

def build_comparison_table(all_diagnostics: dict) -> pd.DataFrame:
    rows = []
    for tf in TIMEFRAMES:
        d = all_diagnostics[tf]
        funnel = d["funnel"]
        labels = d["labels_is"]
        prob = d.get("prob_dist", {}).get("prob_extreme", {})
        diag = d["diag_is"]
        met = d.get("met_is", {})
        
        pos = labels.get(1, 0)
        neg = labels.get(-1, 0)
        total_labels = pos + neg + labels.get(0, 0)
        pos_ratio = pos / total_labels if total_labels > 0 else 0
        
        rows.append({
            "Metric": tf,
            "Raw Bars": funnel.raw_bars,
            "Candidate Signals": funnel.trend_pullback_signals,
            "Positive Labels": pos,
            "HMM Samples": funnel.after_tbm,
            "XGB Samples": funnel.after_tbm,
            "Mean Probability": prob.get("mean", np.nan),
            "Median Probability": prob.get("median", np.nan),
            "Threshold": pipeline[tf].threshold if pipeline[tf].threshold else np.nan,
            "Accepted Trades": diag["after_probability_filter"],
            "OOS Trades": d["diag_oos"]["executed_trades"],
            "Win Rate (IS)": met.get("win_rate", 0),
            "PF (IS)": met.get("profit_factor", 0),
        })
    
    df = pd.DataFrame(rows).set_index("Metric").T
    return df

comp = build_comparison_table(all_diagnostics)
print(f"\n{'='*60}")
print("TIMEFRAME COMPARISON TABLE")
print(f"{'='*60}")
print(comp.to_string())

# Highlight differences > 15%
print(f"\n{'='*60}")
print("DIFFERENCES > 15%")
print(f"{'='*60}")
if len(comp.columns) == 2:
    m15 = comp[TIMEFRAMES[0]]
    m5 = comp[TIMEFRAMES[1]]
    for idx in comp.index:
        v1 = m15[idx]
        v2 = m5[idx]
        if pd.isna(v1) or pd.isna(v2) or v1 == 0 or v2 == 0:
            continue
        diff = abs(v1 - v2) / max(abs(v1), abs(v2), 1e-12)
        if diff > 0.15:
            print(f"  {idx}: {v1:.2f} vs {v2:.2f} ({diff*100:.1f}% diff)")

# --- Phase 12: Root Cause Warnings ---
print(f"\n{'='*60}")
print("ROOT CAUSE DETECTION")
print(f"{'='*60}")

reference_tf = "M5"
target_tf = "M15"
ref = all_diagnostics[reference_tf]
tgt = all_diagnostics[target_tf]

# Check 1: Candidate generation collapse
if tgt["funnel"].trend_pullback_signals < 0.5 * ref["funnel"].trend_pullback_signals:
    print("WARNING: Candidate generation collapse in M15 relative to M5")

# Check 2: Class imbalance
pos = tgt["labels_is"].get(1, 0)
neg = tgt["labels_is"].get(-1, 0)
neu = tgt["labels_is"].get(0, 0)
total = pos + neg + neu
if total > 0 and pos / total < 0.15:
    print("WARNING: Severe class imbalance in M15 (<15% positive)")

# Check 3: Probability calibration issue
prob_mean = tgt.get("prob_dist", {}).get("prob_extreme", {}).get("mean", 1.0)
thr = pipeline["M15"].threshold if pipeline["M15"].threshold else 0.15
if prob_mean < thr:
    print("WARNING: Probability calibration issue (mean prob < threshold)")

# Check 4: HMM state instability
hmm_occ = tgt.get("hmm", {}).get("occupancy", {})
for s, info in hmm_occ.items():
    if info["pct"] < 5:
        print(f"WARNING: HMM state instability (State {s} occupancy {info['pct']:.1f}%)")

# Check 5: Feature loss
m15_loss = (tgt["funnel"].raw_bars - tgt["funnel"].after_nan_removal) / max(tgt["funnel"].raw_bars, 1)
m5_loss = (ref["funnel"].raw_bars - ref["funnel"].after_nan_removal) / max(ref["funnel"].raw_bars, 1)
if m15_loss > m5_loss * 1.5 and m15_loss > 0.01:
    print(f"WARNING: M15 losing more rows to feature engineering ({m15_loss*100:.1f}% vs {m5_loss*100:.1f}%)")

# Check 6: Signal loss vs Strategy Tester
st_cmp = tgt.get("st_cmp", {})
pct_lost = st_cmp.get("pct_lost")
if pct_lost is not None and abs(pct_lost) > 20:
    print(f"WARNING: Signal Loss Detected vs Strategy Tester ({pct_lost:.1f}%)")

print("\nRoot cause detection complete.")
============================================================
TIMEFRAME COMPARISON TABLE
============================================================
Metric                        M15             M5
Raw Bars            116101.000000  347571.000000
Candidate Signals     6725.000000   19296.000000
Positive Labels      20934.000000  115685.000000
HMM Samples          92682.000000  277857.000000
XGB Samples          92682.000000  277857.000000
Mean Probability         0.450516       0.828328
Median Probability       0.000053       0.999903
Threshold                0.150000       0.150000
Accepted Trades        483.000000    1786.000000
OOS Trades              65.000000     499.000000
Win Rate (IS)            0.544021       0.585882
PF (IS)                  2.542452       2.800003

============================================================
DIFFERENCES > 15%
============================================================
  Raw Bars: 116101.00 vs 347571.00 (66.6% diff)
  Candidate Signals: 6725.00 vs 19296.00 (65.1% diff)
  Positive Labels: 20934.00 vs 115685.00 (81.9% diff)
  HMM Samples: 92682.00 vs 277857.00 (66.6% diff)
  XGB Samples: 92682.00 vs 277857.00 (66.6% diff)
  Mean Probability: 0.45 vs 0.83 (45.6% diff)
  Median Probability: 0.00 vs 1.00 (100.0% diff)
  Accepted Trades: 483.00 vs 1786.00 (73.0% diff)
  OOS Trades: 65.00 vs 499.00 (87.0% diff)

============================================================
ROOT CAUSE DETECTION
============================================================
WARNING: Candidate generation collapse in M15 relative to M5
WARNING: Signal Loss Detected vs Strategy Tester (240.7%)

Root cause detection complete.
In [30]:
# ============================================================
# Phase 13: Export Diagnostic Reports
# ============================================================

import os

os.makedirs("reports", exist_ok=True)

for tf in TIMEFRAMES:
    d = all_diagnostics[tf]
    funnel = d["funnel"]
    
    report = {
        "timeframe": tf,
        "raw_bars": funnel.raw_bars,
        "feature_rows": funnel.feature_rows,
        "after_nan_removal": funnel.after_nan_removal,
        "after_tbm": funnel.after_tbm,
        "trend_pullback_signals": funnel.trend_pullback_signals,
        "after_session_filter": funnel.after_session_filter,
        "after_regime_filter": funnel.after_regime_filter,
        "after_probability_filter": funnel.after_probability_filter,
        "executed_trades_is": funnel.executed_trades,
        "winning_trades_is": funnel.winning_trades,
        "losing_trades_is": funnel.losing_trades,
        "executed_trades_oos": d["diag_oos"]["executed_trades"],
        "positive_labels_is": d["labels_is"].get(1, 0),
        "negative_labels_is": d["labels_is"].get(-1, 0),
        "neutral_labels_is": d["labels_is"].get(0, 0),
        "positive_labels_oos": d["labels_oos"].get(1, 0),
        "negative_labels_oos": d["labels_oos"].get(-1, 0),
        "neutral_labels_oos": d["labels_oos"].get(0, 0),
        "hmm_converged": True if d["hmm"] else False,
        "prob_mean": d.get("prob_dist", {}).get("prob_extreme", {}).get("mean", np.nan),
        "prob_median": d.get("prob_dist", {}).get("prob_extreme", {}).get("median", np.nan),
        "prob_std": d.get("prob_dist", {}).get("prob_extreme", {}).get("std", np.nan),
        "brier_score": d.get("calibration", {}).get("brier_score", np.nan),
        "log_loss": d.get("calibration", {}).get("log_loss", np.nan),
        "roc_auc": d.get("calibration", {}).get("roc_auc", np.nan),
        "avg_precision": d.get("calibration", {}).get("average_precision", np.nan),
    }
    
    for s, info in d.get("hmm", {}).get("occupancy", {}).items():
        report[f"hmm_state_{s}_occupancy_pct"] = info["pct"]
        report[f"hmm_state_{s}_samples"] = info["count"]
    
    df_rep = pd.DataFrame([report])
    out_path = f"reports/Explorer_Diagnostics_{tf}.csv"
    df_rep.to_csv(out_path, index=False)
    print(f"Exported: {out_path}")

comp = build_comparison_table(all_diagnostics)
comp.to_csv("reports/Explorer_Timeframe_Comparison.csv")
print("Exported: reports/Explorer_Timeframe_Comparison.csv")

print("\nAll diagnostic reports exported to reports/")
Exported: reports/Explorer_Diagnostics_M15.csv
Exported: reports/Explorer_Diagnostics_M5.csv
Exported: reports/Explorer_Timeframe_Comparison.csv

All diagnostic reports exported to reports/

Traceability Layer (Added)

The cells below extend this notebook's existing diagnostic instrumentation layer (PipelineFunnel, FeatureLossAudit, RejectionBreakdown, build_features_with_trace, diagnostic_signal_trace/diagnostic_backtest_trace, hmm_diagnostics, probability_distribution, calibration_metrics, compare_strategy_tester_signals, build_comparison_table) with persistent, cross-notebook candidate_id identity. They are purely additive: CPCV, the Grid Sensitivity Plateau tools, the HMM/XGBoost composite architecture, Strategy logic, and the ProductionRiskCircuitBreaker are all unmodified. No existing cell's behavior changes; new functions wrap or duplicate-and-extend existing ones only where new outputs (e.g. entry_time/exit_time on trades, confusion matrix) were needed for observability that wasn't already exposed.

  • Phase 1: CandidateTrade + make_candidate_id (identical formula to Strategy_Tester.ipynb, so IDs match across notebooks without needing to share objects)
  • Phase 3: PipelineProfiler wired through TREND_PULLBACK -> LABEL_FILTER -> HMM -> PROBABILITY_FILTER -> EXECUTED with real candidate_id sets
  • Phase 4: get_candidate_lifecycle(candidate_id, tf, all_candidates_by_tf) for full audit trails
  • Phase 5: attach_candidate_ids / assert_candidate_id_preserved traceable-dataframe helpers
  • Phase 6: Signal Loss Report per timeframe
  • Phase 7: RejectionReason enum applied per candidate + top rejection reasons
  • Phase 8: Probability Drift report (IS Median -> OOS Median -> Difference, with skew/kurtosis and automatic confidence-collapse flagging)
  • Phase 11: HMM average holding time per hidden state (via run_ml_filtered_backtest_traced, a new wrapper that exposes entry_time/exit_time/entry_regime already produced by the existing numba engine, without changing that engine)
  • Phase 12: XGBoost confusion matrix, precision/recall/F1, PR AUC, calibration error
  • Phase 13: Strategy Tester <-> Explorer consistency table (Generated/Loaded/TBM Pass/HMM Pass/XGB Pass/Executed) using persistent candidate_ids, reading reports/candidate_trades_export.csv
  • Phase 14: Explorer_Timeframe_Report.csv with the full required metrics + >15% difference flags
  • Phase 15: RootCauseAnalyzer producing a ranked 'Likely Root Cause' table per timeframe

Run these cells after the existing Cells 0-30 have run, since they reuse pipeline, ML_TARGET_PARAMS, all_diagnostics, and the diagnostic helper functions defined earlier.

In [31]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 1: Candidate Trade Object
# Identical ID formula to Strategy_Tester.ipynb so candidate_id values
# match across both notebooks for the same (timeframe, timestamp, direction).
# Pure addition: does not modify CPCV, HMM/XGBoost architecture, or
# Strategy logic defined earlier in this notebook.
# ============================================================
from dataclasses import dataclass, asdict
from enum import Enum
import hashlib


@dataclass
class CandidateTrade:
    candidate_id: str
    timeframe: str
    timestamp: pd.Timestamp
    direction: str
    entry_price: float
    stop_price: float
    target_price: float
    strategy_name: str
    parameter_set_id: str
    hmm_state: int | None = None
    xgb_probability: float | None = None
    accepted: bool = False
    rejection_reason: str | None = None


def make_candidate_id(timeframe: str, timestamp, direction: str) -> str:
    key = f"{timeframe}_{timestamp}_{direction}"
    return hashlib.sha256(key.encode()).hexdigest()[:16]


def parameter_set_id(params: dict) -> str:
    blob = json.dumps(params, sort_keys=True, default=str)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]


class RejectionReason(str, Enum):
    LOW_PROBABILITY = "LOW_PROBABILITY"
    SESSION = "SESSION"
    ATR = "ATR"
    TBM = "TBM"
    DUPLICATE = "DUPLICATE"
    MAX_EXPOSURE = "MAX_EXPOSURE"
    INVALID_FEATURE = "INVALID_FEATURE"
    UNKNOWN = "UNKNOWN"


print("Phase 1 & 7: CandidateTrade, make_candidate_id, parameter_set_id, RejectionReason ready (Explorer).")
Phase 1 & 7: CandidateTrade, make_candidate_id, parameter_set_id, RejectionReason ready (Explorer).
In [32]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 3: Pipeline Profiler (same design as Strategy Tester)
# ============================================================
class PipelineProfiler:
    """Append-only observability recorder shared in spirit with Strategy_Tester.ipynb."""

    def __init__(self):
        self.records = []

    def record(self, timeframe, stage, candidate_ids, metadata=None):
        ids = set(candidate_ids)
        self.records.append(
            {
                "timeframe": timeframe,
                "stage": stage,
                "n_candidates": len(ids),
                "candidate_ids": ids,
                "metadata": metadata or {},
            }
        )

    def stage_ids(self, timeframe, stage):
        matches = [
            r["candidate_ids"]
            for r in self.records
            if r["timeframe"] == timeframe and r["stage"] == stage
        ]
        return matches[-1] if matches else set()

    def funnel_table(self, timeframe):
        rows = [
            {"stage": r["stage"], "n_candidates": r["n_candidates"], **r["metadata"]}
            for r in self.records
            if r["timeframe"] == timeframe
        ]
        return pd.DataFrame(rows)

    def signal_loss_report(self, timeframe, stage_order):
        rows = []
        prev_ids, prev_stage = None, None
        for stage in stage_order:
            cur_ids = self.stage_ids(timeframe, stage)
            if prev_ids is not None:
                lost = prev_ids - cur_ids
                rows.append(
                    {
                        "from_stage": prev_stage,
                        "to_stage": stage,
                        "stage_removed": stage,
                        "lost_count": len(lost),
                        "lost_candidate_ids": lost,
                    }
                )
            prev_ids, prev_stage = cur_ids, stage
        return pd.DataFrame(rows)


PIPELINE_STAGES = [
    "RAW_BARS",
    "FEATURE_ENGINEERING",
    "TREND_PULLBACK",
    "TBM",
    "LABEL_FILTER",
    "HMM",
    "XGBOOST",
    "PROBABILITY_FILTER",
    "BACKTEST",
    "RISK_MANAGER",
    "EXECUTED",
]

profiler = PipelineProfiler()
print("Phase 3: PipelineProfiler ready (Explorer). Stages:", PIPELINE_STAGES)
Phase 3: PipelineProfiler ready (Explorer). Stages: ['RAW_BARS', 'FEATURE_ENGINEERING', 'TREND_PULLBACK', 'TBM', 'LABEL_FILTER', 'HMM', 'XGBOOST', 'PROBABILITY_FILTER', 'BACKTEST', 'RISK_MANAGER', 'EXECUTED']
In [33]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 5: Traceable DataFrame helpers
# ============================================================
def attach_candidate_ids(df: pd.DataFrame, timeframe: str, direction_source: pd.Series) -> pd.DataFrame:
    """
    Adds a candidate_id column for every row where a direction was produced
    (direction_source != 0). Rows with no signal get candidate_id=None.
    """
    out = df.copy()
    directions = direction_source.reindex(out.index).fillna(0).astype(int)
    ids = [
        make_candidate_id(timeframe, ts, "BUY" if d == 1 else "SELL") if d != 0 else None
        for ts, d in zip(out.index, directions)
    ]
    out["candidate_id"] = ids
    assert "candidate_id" in out.columns
    return out


def assert_candidate_id_preserved(df: pd.DataFrame, stage_name: str):
    assert "candidate_id" in df.columns, f"candidate_id lost after stage: {stage_name}"


print("Phase 5: attach_candidate_ids / assert_candidate_id_preserved ready.")
Phase 5: attach_candidate_ids / assert_candidate_id_preserved ready.
In [34]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 5/11: richer trades_df wrapper
# Reuses the SAME compiled _run_backtest_numba engine and
# TrendPullbackStrategy already defined earlier in this notebook.
# Does not modify run_ml_filtered_backtest; adds a parallel function
# that exposes more of the engine's own existing outputs
# (entry_time/exit_time/entry_regime/candidate_id) for observability.
# ============================================================
def run_ml_filtered_backtest_traced(timeframe, df, ml_probs, base_params, xgb_threshold):
    strategy = TrendPullbackStrategy()
    raw_signals = strategy.generate_signals(df, base_params)
    trend_mask = df["regime_code"].to_numpy() == 1
    raw_signals = raw_signals.where(pd.Series(trend_mask, index=df.index), 0)

    raw_arr = raw_signals.to_numpy(dtype=np.int8)
    prob_down = ml_probs[:, 0]
    prob_up = ml_probs[:, 2]
    filtered_arr = raw_arr.copy()
    filtered_arr[(raw_arr == 1) & (prob_up < xgb_threshold)] = 0
    filtered_arr[(raw_arr == -1) & (prob_down < xgb_threshold)] = 0

    idx = df.index
    time_minutes = (idx.view("int64") // 60000000000).astype(np.int64)
    sig = filtered_arr

    high = df["High"].to_numpy(dtype=np.float64)
    low = df["Low"].to_numpy(dtype=np.float64)
    close = df["Close"].to_numpy(dtype=np.float64)
    spread = df["spread"].fillna(40.0).to_numpy(dtype=np.float64)
    atr14 = df["atr14"].to_numpy(dtype=np.float64)
    regime_code = df["regime_code"].to_numpy(dtype=np.int32)

    is_m5 = 1 if timeframe == "M5" else 0
    exit_model_map = {
        "fixed_tp": 0,
        "mr_exit": 1,
        "fixed_tp_plus_mr": 2,
        "partial_tp_plus_mr": 3,
        "partial_tp_mr_time_stop": 4,
    }
    exit_mode_code = int(exit_model_map.get(base_params.get("exit_model", "fixed_tp"), 0))

    initial_bal = INITIAL_BALANCE_CENTS
    sl_dist = float(
        base_params.get(
            "atr_stop",
            base_params.get("sl_atr", base_params.get("atr_mult_sl", base_params.get("stop_loss_multiplier", 2.0))),
        )
    )
    tp_dist = float(
        base_params.get(
            "atr_target",
            base_params.get("tp_atr", base_params.get("atr_mult_tp", base_params.get("take_profit_multiplier", 2.0))),
        )
    )
    leg_a_tp = float(base_params.get("leg_a_atr_target", base_params.get("leg_a_tp", 1.0)))

    (
        trade_entry_idx,
        trade_exit_idx,
        trade_leg,
        trade_side,
        trade_entry_px,
        trade_exit_px,
        trade_move_pips,
        trade_pnl_cents,
        trade_reason,
        trade_entry_regime,
        trade_count,
        eq,
        eq_count,
        ending_balance,
    ) = _run_backtest_numba(
        sig,
        high,
        low,
        close,
        spread,
        atr14,
        regime_code,
        time_minutes,
        sl_dist,
        tp_dist,
        leg_a_tp,
        exit_mode_code,
        float(base_params.get("time_stop_minutes", -1.0)),
        float(base_params.get("trail_mult", 0.0)),
        float(initial_bal),
        0.10,
        100.0,
        0.30,
        40.0,
        0.0,
        int(is_m5),
    )

    if trade_count == 0:
        return pd.DataFrame(), compute_metrics(pd.DataFrame(), [], initial_bal, float(ending_balance))

    regime_map = {1: "TREND", 2: "SHOCK", 3: "MR"}
    trades_df = pd.DataFrame(
        {
            "entry_time": idx[trade_entry_idx[:trade_count]],
            "exit_time": idx[trade_exit_idx[:trade_count]],
            "side": np.where(trade_side[:trade_count] == 1, "BUY", "SELL"),
            "entry_price": trade_entry_px[:trade_count],
            "exit_price": trade_exit_px[:trade_count],
            "pnl_cents": trade_pnl_cents[:trade_count],
            "entry_regime": [regime_map.get(int(x), "UNKNOWN") for x in trade_entry_regime[:trade_count]],
        }
    )
    trades_df["candidate_id"] = [
        make_candidate_id(timeframe, ts, side) for ts, side in zip(trades_df["entry_time"], trades_df["side"])
    ]
    metrics = compute_metrics(trades_df, eq[:eq_count].tolist(), initial_bal, float(ending_balance))
    return trades_df, metrics


print("Phase 5/11: run_ml_filtered_backtest_traced ready (adds entry_time/exit_time/entry_regime/candidate_id).")
Phase 5/11: run_ml_filtered_backtest_traced ready (adds entry_time/exit_time/entry_regime/candidate_id).
In [35]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 8: Probability Diagnostics (IS vs OOS)
# ============================================================
from scipy.stats import skew, kurtosis


def probability_diagnostics_full(model, df):
    if model is None:
        return {}
    probs = model.predict_proba_raw(df)
    p_extreme = np.maximum(probs[:, 0], probs[:, 2])
    return {
        "mean": float(np.mean(p_extreme)),
        "median": float(np.median(p_extreme)),
        "std": float(np.std(p_extreme)),
        "skew": float(skew(p_extreme)),
        "kurtosis": float(kurtosis(p_extreme)),
        "p10": float(np.percentile(p_extreme, 10)),
        "p25": float(np.percentile(p_extreme, 25)),
        "p50": float(np.percentile(p_extreme, 50)),
        "p75": float(np.percentile(p_extreme, 75)),
        "p90": float(np.percentile(p_extreme, 90)),
    }


def probability_drift_report(all_diag_v2, collapse_threshold=0.10):
    rows = []
    for tf in TIMEFRAMES:
        is_stats = all_diag_v2[tf].get("prob_diag_is", {})
        oos_stats = all_diag_v2[tf].get("prob_diag_oos", {})
        if not is_stats or not oos_stats:
            continue
        diff = is_stats["median"] - oos_stats["median"]
        rows.append(
            {
                "timeframe": tf,
                "is_median": is_stats["median"],
                "oos_median": oos_stats["median"],
                "difference": diff,
                "is_mean": is_stats["mean"],
                "oos_mean": oos_stats["mean"],
                "is_std": is_stats["std"],
                "oos_std": oos_stats["std"],
                "confidence_collapse": bool(diff > collapse_threshold),
            }
        )
    return pd.DataFrame(rows)


print("Phase 8: probability_diagnostics_full / probability_drift_report ready.")
Phase 8: probability_diagnostics_full / probability_drift_report ready.
In [36]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 11: HMM holding-time extension
# Wraps (does not modify) the existing hmm_diagnostics() function.
# ============================================================
def hmm_diagnostics_v2(model, df, trades_df=None):
    base = hmm_diagnostics(model, df)
    if not base or trades_df is None or trades_df.empty or "entry_time" not in trades_df.columns:
        return base
    X_hmm = df[model.hmm_features_].to_numpy(dtype=float)
    X_hmm = model.hmm_scaler.transform(X_hmm)
    states = model.hmm.predict(X_hmm)
    state_by_time = pd.Series(states, index=df.index)
    holding_minutes = (trades_df["exit_time"] - trades_df["entry_time"]).dt.total_seconds() / 60.0
    entry_states = trades_df["entry_time"].map(state_by_time)
    for s in range(model.n_components):
        mask = entry_states == s
        avg_hold = float(holding_minutes[mask].mean()) if mask.any() else float("nan")
        if s in base.get("state_stats", {}):
            base["state_stats"][s]["avg_holding_minutes"] = avg_hold
    return base


print("Phase 11: hmm_diagnostics_v2 ready (adds avg holding time per hidden state).")
Phase 11: hmm_diagnostics_v2 ready (adds avg holding time per hidden state).
In [37]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 12: XGBoost Observability extension
# Wraps (does not modify) the existing calibration_metrics() function.
# ============================================================
from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score


def calibration_metrics_v2(model, df):
    base = calibration_metrics(model, df)
    if not base or model is None:
        return base
    probs = model.predict_proba_raw(df)
    y_true = df["tb_label"].to_numpy(dtype=int)
    y_cls_true = np.where(y_true == -1, 0, np.where(y_true == 1, 2, 1))
    y_cls_pred = np.argmax(probs, axis=1)

    cm = confusion_matrix(y_cls_true, y_cls_pred, labels=[0, 1, 2])
    precision = precision_score(y_cls_true, y_cls_pred, average="macro", zero_division=0)
    recall = recall_score(y_cls_true, y_cls_pred, average="macro", zero_division=0)
    f1 = f1_score(y_cls_true, y_cls_pred, average="macro", zero_division=0)

    prob_true = np.array(base.get("calibration_prob_true", []))
    prob_pred = np.array(base.get("calibration_prob_pred", []))
    calibration_error = float(np.mean(np.abs(prob_true - prob_pred))) if len(prob_true) else float("nan")

    out = dict(base)
    out.update(
        {
            "confusion_matrix": cm.tolist(),
            "precision_macro": float(precision),
            "recall_macro": float(recall),
            "f1_macro": float(f1),
            "pr_auc": base.get("average_precision", float("nan")),
            "calibration_error": calibration_error,
        }
    )
    return out


print("Phase 12: calibration_metrics_v2 ready (confusion matrix, precision/recall/F1, calibration error, PR AUC).")
Phase 12: calibration_metrics_v2 ready (confusion matrix, precision/recall/F1, calibration error, PR AUC).
In [38]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 4: Candidate Lifecycle Viewer
# ============================================================
def get_candidate_lifecycle(candidate_id, tf, all_candidates_by_tf, verbose=True):
    """Phase 4: full audit trail for one candidate_id."""
    cand = all_candidates_by_tf.get(tf, {}).get(candidate_id)
    if cand is None:
        if verbose:
            print(f"Candidate {candidate_id} not found for {tf}.")
        return None

    stages = ["Generated", "Passed Feature Engineering", "Passed TBM"]
    if cand.hmm_state is not None:
        stages.append(f"Assigned HMM State = {cand.hmm_state}")
    if cand.xgb_probability is not None:
        stages.append(f"XGB Probability = {cand.xgb_probability:.3f}")
    if cand.accepted:
        stages.append("Threshold PASS")
        stages.append("Executed")
    else:
        stages.append("Threshold/Filter FAIL")
        stages.append(f"Rejected ({cand.rejection_reason})")

    if verbose:
        print(f"\nCandidate Lifecycle: {candidate_id}  [{tf}]")
        print(f"  Timestamp : {cand.timestamp}")
        print(f"  Direction : {cand.direction}")
        print(f"  Entry/Stop/Target : {cand.entry_price:.4f} / {cand.stop_price:.4f} / {cand.target_price:.4f}")
        print("  " + "\n  -> ".join(stages))
    return cand


print("Phase 4: get_candidate_lifecycle ready.")
Phase 4: get_candidate_lifecycle ready.
In [39]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 13: Strategy Tester <-> Explorer Consistency
# ============================================================
def build_st_explorer_consistency_table(candidate_summary_by_tf):
    st_path = Path("reports/candidate_trades_export.csv")
    st_df = pd.read_csv(st_path) if st_path.exists() else pd.DataFrame()

    rows = {}
    for tf in TIMEFRAMES:
        st_ids = set(st_df.loc[st_df["timeframe"] == tf, "candidate_id"]) if not st_df.empty else set()
        exp = candidate_summary_by_tf.get(tf, {})
        generated = exp.get("trend_pullback_ids", set())
        loaded = (generated & st_ids) if st_ids else generated
        tbm_pass = exp.get("tbm_ids", set())
        hmm_pass = exp.get("hmm_ids", set())
        xgb_pass = exp.get("probability_filter_ids", set())
        executed = exp.get("executed_ids", set())

        rows[tf] = {
            "Generated": len(generated),
            "Loaded": len(loaded),
            "TBM Pass": len(tbm_pass),
            "HMM Pass": len(hmm_pass),
            "XGB Pass": len(xgb_pass),
            "Executed": len(executed),
        }

    table = pd.DataFrame(rows)
    for tf in TIMEFRAMES:
        gen = table.loc["Generated", tf]
        exe = table.loc["Executed", tf]
        loss_pct = (gen - exe) / gen * 100 if gen > 0 else 0.0
        if loss_pct > 10:
            print(f"WARNING: {tf} signal loss {loss_pct:.1f}% (> 10%) between Generated and Executed")
    return table


print("Phase 13: build_st_explorer_consistency_table ready.")
Phase 13: build_st_explorer_consistency_table ready.
In [40]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 14: Timeframe Comparison Report
# ============================================================
def build_timeframe_report(all_diag_v2):
    rows = {}
    for tf in TIMEFRAMES:
        d = all_diag_v2[tf]
        met_is = d.get("met_is", {}) or {}
        funnel = d["funnel"]
        rows[tf] = {
            "Candidate Trades": funnel.trend_pullback_signals,
            "Positive Labels": d["labels_is"].get(1, 0),
            "Mean Probability": d.get("prob_diag_is", {}).get("mean", np.nan),
            "Median Probability": d.get("prob_diag_is", {}).get("median", np.nan),
            "Threshold": pipeline[tf].threshold if pipeline[tf].threshold is not None else np.nan,
            "Accepted Trades": d["diag_is"]["after_probability_filter"],
            "Rejected Trades": funnel.trend_pullback_signals - d["diag_is"]["executed_trades"],
            "OOS Trades": d["diag_oos"]["executed_trades"],
            "Win Rate": met_is.get("win_rate", np.nan),
            "PF": met_is.get("profit_factor", np.nan),
            "Brier Score": d.get("calibration_v2", {}).get("brier_score", np.nan),
            "Calibration Error": d.get("calibration_v2", {}).get("calibration_error", np.nan),
        }
    report = pd.DataFrame(rows)
    if len(TIMEFRAMES) == 2:
        a, b = TIMEFRAMES[0], TIMEFRAMES[1]
        diffs = []
        for idx in report.index:
            v1, v2 = report.loc[idx, a], report.loc[idx, b]
            if pd.isna(v1) or pd.isna(v2) or max(abs(v1), abs(v2)) == 0:
                diffs.append(np.nan)
                continue
            diffs.append(abs(v1 - v2) / max(abs(v1), abs(v2)) * 100)
        report["pct_diff"] = diffs
        report["flag_gt_15pct"] = report["pct_diff"] > 15
    return report


print("Phase 14: build_timeframe_report ready.")
Phase 14: build_timeframe_report ready.
In [41]:
# ============================================================
# TRACEABILITY LAYER (ADDED) — Phase 15: Root Cause Analyzer
# ============================================================
class RootCauseAnalyzer:
    """Ranks likely root causes instead of dumping raw metrics."""

    def analyse(self, tf, diag, threshold, sparse_hmm_threshold_pct=5.0):
        issues = []

        prob_mean = diag.get("prob_diag_is", {}).get("mean", np.nan)
        if not np.isnan(prob_mean) and prob_mean < threshold:
            issues.append(("Probability calibration", f"mean probability {prob_mean:.3f} < threshold {threshold:.3f}"))

        labels = diag.get("labels_is", {})
        total = sum(labels.values()) if labels else 0
        pos_ratio = labels.get(1, 0) / total if total else np.nan
        if not np.isnan(pos_ratio) and pos_ratio < 0.15:
            issues.append(("Class imbalance", f"positive label ratio {pos_ratio:.1%} < 15%"))

        funnel = diag["funnel"]
        signal_loss = (
            (funnel.trend_pullback_signals - funnel.executed_trades) / funnel.trend_pullback_signals
            if funnel.trend_pullback_signals > 0
            else np.nan
        )
        if not np.isnan(signal_loss) and signal_loss > 0.20:
            issues.append(
                (
                    "Signal generation collapse",
                    f"signal loss {signal_loss:.1%} > 20% (generated {funnel.trend_pullback_signals} -> executed {funnel.executed_trades})",
                )
            )

        hmm_occ = diag.get("hmm_v2", {}).get("occupancy", {})
        sparse_states = [s for s, info in hmm_occ.items() if info["pct"] < sparse_hmm_threshold_pct]
        if sparse_states:
            issues.append(("Sparse HMM state", f"states {sparse_states} below {sparse_hmm_threshold_pct}% occupancy"))

        if not issues:
            issues.append(("Unknown", "No rule matched; manual review required"))

        out = pd.DataFrame(issues, columns=["issue", "evidence"])
        out["timeframe"] = tf
        out["rank"] = range(1, len(out) + 1)
        return out

    def analyse_all(self, all_diag_v2, thresholds_by_tf):
        frames = [self.analyse(tf, all_diag_v2[tf], thresholds_by_tf.get(tf, 0.15)) for tf in all_diag_v2]
        return pd.concat(frames, ignore_index=True)


print("Phase 15: RootCauseAnalyzer ready.")
Phase 15: RootCauseAnalyzer ready.
In [42]:
# ============================================================
# MAIN CANDIDATE-LEVEL DIAGNOSTIC RUNNER (ADDED) — Phases 1-16
# Reuses existing functions (build_features_with_trace,
# generate_signals_diagnostic, hmm/xgboost diagnostics, pipeline,
# all_diagnostics) already computed earlier in this notebook.
# Does not modify or re-run CPCV, HMM/XGBoost fitting, or Strategy logic;
# it performs additional inference-only passes for observability.
# ============================================================
all_diag_v2 = {}
all_candidates_by_tf = {}

for tf in TIMEFRAMES:
    p = pipeline[tf]
    base_params = dict(ML_TARGET_PARAMS[tf]["parameters"])
    base_params["exit_model"] = ML_TARGET_PARAMS[tf]["exit_model"]
    model = p.model
    thr = float(p.threshold) if p.threshold is not None else 0.15

    feat_is, _, _ = build_features_with_trace(p.train_df, tf)
    feat_oos, _, _ = build_features_with_trace(p.oos_df, tf)

    sig_pre_is, sig_post_is = generate_signals_diagnostic(feat_is, base_params)
    trend_mask_is = feat_is["regime_code"].to_numpy() == 1
    regime_arr_is = sig_post_is.where(pd.Series(trend_mask_is, index=feat_is.index), 0)

    probs_is = model.predict_proba_raw(feat_is) if model is not None else None
    if probs_is is not None:
        filtered_arr_is = regime_arr_is.to_numpy(dtype=np.int8).copy()
        filtered_arr_is[(regime_arr_is.to_numpy() == 1) & (probs_is[:, 2] < thr)] = 0
        filtered_arr_is[(regime_arr_is.to_numpy() == -1) & (probs_is[:, 0] < thr)] = 0
    else:
        filtered_arr_is = np.zeros(len(feat_is), dtype=np.int8)

    if model is not None:
        trades_is_v2, met_is_v2 = run_ml_filtered_backtest_traced(tf, feat_is, probs_is, base_params, thr)
        probs_oos = model.predict_proba_raw(feat_oos)
        trades_oos_v2, met_oos_v2 = run_ml_filtered_backtest_traced(tf, feat_oos, probs_oos, base_params, thr)
    else:
        trades_is_v2, met_is_v2 = pd.DataFrame(), {}
        trades_oos_v2, met_oos_v2 = pd.DataFrame(), {}

    def ids_for(arr, idx, tf=tf):
        out = set()
        for ts, s in zip(idx, arr):
            if s != 0:
                out.add(make_candidate_id(tf, ts, "BUY" if s == 1 else "SELL"))
        return out

    trend_pullback_ids = ids_for(sig_pre_is.to_numpy(), feat_is.index)
    session_ids = ids_for(sig_post_is.to_numpy(), feat_is.index)
    regime_ids = ids_for(regime_arr_is.to_numpy(), feat_is.index)
    prob_filter_ids = ids_for(filtered_arr_is, feat_is.index)
    executed_ids = set(trades_is_v2["candidate_id"]) if not trades_is_v2.empty else set()

    profiler.record(tf, "RAW_BARS", [], metadata={"n_rows": len(p.raw_all)})
    profiler.record(tf, "FEATURE_ENGINEERING", [], metadata={"n_rows": len(feat_is)})
    profiler.record(tf, "TREND_PULLBACK", trend_pullback_ids)
    profiler.record(
        tf,
        "TBM",
        trend_pullback_ids,
        metadata={"note": "row-level TBM survival captured via FeatureLossAudit; candidates only generated post-TBM"},
    )
    profiler.record(tf, "LABEL_FILTER", session_ids, metadata={"filter": "session"})
    profiler.record(tf, "HMM", regime_ids, metadata={"filter": "trend_regime"})
    profiler.record(tf, "XGBOOST", prob_filter_ids, metadata={"filter": "probability_threshold"})
    profiler.record(tf, "PROBABILITY_FILTER", prob_filter_ids)
    profiler.record(tf, "BACKTEST", prob_filter_ids)
    profiler.record(tf, "RISK_MANAGER", executed_ids)
    profiler.record(tf, "EXECUTED", executed_ids)

    cand_registry = {}
    hmm_states_arr = None
    if model is not None and getattr(model, "hmm", None) is not None:
        X_hmm = feat_is[model.hmm_features_].to_numpy(dtype=float)
        X_hmm = model.hmm_scaler.transform(X_hmm)
        hmm_states_arr = model.hmm.predict(X_hmm)

    pset_id = parameter_set_id(base_params)
    sig_pre_arr = sig_pre_is.to_numpy()
    for pos, ts in enumerate(feat_is.index):
        s = int(sig_pre_arr[pos])
        if s == 0:
            continue
        direction = "BUY" if s == 1 else "SELL"
        cid = make_candidate_id(tf, ts, direction)

        hmm_state = int(hmm_states_arr[pos]) if hmm_states_arr is not None else None
        xgb_prob = float(probs_is[pos, 2] if s == 1 else probs_is[pos, 0]) if probs_is is not None else None

        accepted = cid in executed_ids
        # NOTE on rejection_reason mapping: the fixed RejectionReason enum has no
        # dedicated "regime filter" value. Trend-regime rejections are mapped to
        # UNKNOWN below and are always fully explained by the HMM-stage candidate_id
        # set difference in the Signal Loss Report, so no information is lost.
        if cid not in session_ids:
            reason = RejectionReason.SESSION.value
        elif cid not in regime_ids:
            reason = RejectionReason.UNKNOWN.value
        elif cid not in prob_filter_ids:
            reason = RejectionReason.LOW_PROBABILITY.value
        elif not accepted:
            reason = RejectionReason.UNKNOWN.value
        else:
            reason = None

        cand_registry[cid] = CandidateTrade(
            candidate_id=cid,
            timeframe=tf,
            timestamp=ts,
            direction=direction,
            entry_price=float(feat_is["Close"].iloc[pos]),
            stop_price=float("nan"),
            target_price=float("nan"),
            strategy_name="trend_pullback",
            parameter_set_id=pset_id,
            hmm_state=hmm_state,
            xgb_probability=xgb_prob,
            accepted=accepted,
            rejection_reason=reason,
        )
    all_candidates_by_tf[tf] = cand_registry

    prob_diag_is = probability_diagnostics_full(model, feat_is) if model is not None else {}
    prob_diag_oos = probability_diagnostics_full(model, feat_oos) if model is not None else {}
    hmm_diag_v2 = hmm_diagnostics_v2(model, feat_is, trades_is_v2) if model is not None else {}
    calibration_v2 = calibration_metrics_v2(model, feat_is) if model is not None else {}

    diag_is_base = all_diagnostics[tf]["diag_is"]
    diag_oos_base = all_diagnostics[tf]["diag_oos"]

    all_diag_v2[tf] = {
        "funnel": all_diagnostics[tf]["funnel"],
        "labels_is": all_diagnostics[tf]["labels_is"],
        "labels_oos": all_diagnostics[tf]["labels_oos"],
        "diag_is": diag_is_base,
        "diag_oos": diag_oos_base,
        "met_is": met_is_v2 or all_diagnostics[tf]["met_is"],
        "prob_diag_is": prob_diag_is,
        "prob_diag_oos": prob_diag_oos,
        "hmm_v2": hmm_diag_v2,
        "calibration_v2": calibration_v2,
        "trend_pullback_ids": trend_pullback_ids,
        "tbm_ids": trend_pullback_ids,
        "hmm_ids": regime_ids,
        "probability_filter_ids": prob_filter_ids,
        "executed_ids": executed_ids,
    }

print("Phase 1-16 candidate-level diagnostics computed for all timeframes.")
Phase 1-16 candidate-level diagnostics computed for all timeframes.
In [43]:
# ============================================================
# FINAL TRACEABILITY EXPORTS (ADDED) — Phases 4/6/7/8/12/13/14/15
# ============================================================
os.makedirs("reports", exist_ok=True)

# --- Phase 4: sample lifecycle print for one accepted + one rejected candidate per tf ---
for tf in TIMEFRAMES:
    reg = all_candidates_by_tf[tf]
    accepted_ids = [cid for cid, c in reg.items() if c.accepted]
    rejected_ids = [cid for cid, c in reg.items() if not c.accepted]
    if accepted_ids:
        get_candidate_lifecycle(accepted_ids[0], tf, all_candidates_by_tf)
    if rejected_ids:
        get_candidate_lifecycle(rejected_ids[0], tf, all_candidates_by_tf)

# --- Phase 6: Signal Loss Report ---
for tf in TIMEFRAMES:
    loss_df = profiler.signal_loss_report(
        tf, ["TREND_PULLBACK", "LABEL_FILTER", "HMM", "PROBABILITY_FILTER", "EXECUTED"]
    )
    print(f"\nSignal Loss Report \u2014 {tf}")
    if loss_df.empty:
        print("  (no stages recorded)")
    else:
        display(loss_df[["from_stage", "to_stage", "lost_count"]])
    loss_df.drop(columns=["lost_candidate_ids"], errors="ignore").to_csv(
        f"reports/signal_loss_report_{tf}_explorer.csv", index=False
    )

# --- Phase 7: Top rejection reasons ---
for tf in TIMEFRAMES:
    reg = all_candidates_by_tf[tf]
    reasons = pd.Series([c.rejection_reason for c in reg.values() if not c.accepted]).value_counts()
    print(f"\nTop rejection reasons \u2014 {tf}")
    print(reasons.to_string() if not reasons.empty else "  (none)")

# --- Phase 8: Probability Drift ---
drift_df = probability_drift_report(all_diag_v2)
print("\nProbability Drift (IS -> OOS)")
display(drift_df)
drift_df.to_csv("reports/probability_drift.csv", index=False)

# --- Phase 12: XGBoost observability ---
for tf in TIMEFRAMES:
    cal = all_diag_v2[tf]["calibration_v2"]
    if cal:
        print(f"\nXGBoost Observability \u2014 {tf}")
        print(f"  Confusion Matrix (rows=true[down,neutral,up], cols=pred): {cal.get('confusion_matrix')}")
        print(f"  Precision (macro): {cal.get('precision_macro', float('nan')):.3f}")
        print(f"  Recall (macro)   : {cal.get('recall_macro', float('nan')):.3f}")
        print(f"  F1 (macro)       : {cal.get('f1_macro', float('nan')):.3f}")
        print(f"  ROC AUC          : {cal.get('roc_auc', float('nan')):.3f}")
        print(f"  PR AUC           : {cal.get('pr_auc', float('nan')):.3f}")
        print(f"  Brier Score      : {cal.get('brier_score', float('nan')):.4f}")
        print(f"  Calibration Error: {cal.get('calibration_error', float('nan')):.4f}")

# --- Phase 11: HMM holding time ---
for tf in TIMEFRAMES:
    hv2 = all_diag_v2[tf]["hmm_v2"]
    if hv2 and "state_stats" in hv2:
        print(f"\nHMM Holding Time \u2014 {tf}")
        for s, stats in hv2["state_stats"].items():
            print(f"  State {s}: avg_holding_minutes={stats.get('avg_holding_minutes', float('nan'))}")

# --- Phase 13: Strategy Tester <-> Explorer consistency ---
consistency_table = build_st_explorer_consistency_table(all_diag_v2)
print("\nStrategy Tester <-> Explorer Consistency")
display(consistency_table)
consistency_table.to_csv("reports/st_explorer_consistency.csv")

# --- Phase 14: Timeframe Comparison Report ---
timeframe_report = build_timeframe_report(all_diag_v2)
print("\nExplorer Timeframe Report")
display(timeframe_report)
timeframe_report.to_csv("reports/Explorer_Timeframe_Report.csv")

# --- Phase 15: Root Cause Ranking ---
rca = RootCauseAnalyzer()
thresholds_by_tf = {tf: float(pipeline[tf].threshold or 0.15) for tf in TIMEFRAMES}
root_cause_df = rca.analyse_all(all_diag_v2, thresholds_by_tf)
print("\nLikely Root Cause Ranking")
display(root_cause_df)
root_cause_df.to_csv("reports/root_cause_ranking.csv", index=False)

print("\nAll Phase 1-16 traceability reports exported to reports/.")
Candidate Lifecycle: 6e16e8498994832d  [M15]
  Timestamp : 2021-06-11 14:00:00
  Direction : BUY
  Entry/Stop/Target : 1888.3400 / nan / nan
  Generated
  -> Passed Feature Engineering
  -> Passed TBM
  -> Assigned HMM State = 0
  -> XGB Probability = 1.000
  -> Threshold PASS
  -> Executed

Candidate Lifecycle: b35a0e3b7cb8860c  [M15]
  Timestamp : 2021-06-02 15:30:00
  Direction : SELL
  Entry/Stop/Target : 1905.5200 / nan / nan
  Generated
  -> Passed Feature Engineering
  -> Passed TBM
  -> Assigned HMM State = 0
  -> XGB Probability = 0.000
  -> Threshold/Filter FAIL
  -> Rejected (LOW_PROBABILITY)

Candidate Lifecycle: 7ad598cc9ae4c108  [M5]
  Timestamp : 2021-06-01 13:40:00
  Direction : BUY
  Entry/Stop/Target : 1904.9200 / nan / nan
  Generated
  -> Passed Feature Engineering
  -> Passed TBM
  -> Assigned HMM State = 0
  -> XGB Probability = 1.000
  -> Threshold PASS
  -> Executed

Candidate Lifecycle: 734407c2ea8498c4  [M5]
  Timestamp : 2021-06-01 08:05:00
  Direction : BUY
  Entry/Stop/Target : 1910.5900 / nan / nan
  Generated
  -> Passed Feature Engineering
  -> Passed TBM
  -> Assigned HMM State = 1
  -> XGB Probability = 1.000
  -> Threshold/Filter FAIL
  -> Rejected (SESSION)

Signal Loss Report — M15
from_stage to_stage lost_count
0 TREND_PULLBACK LABEL_FILTER 4140
1 LABEL_FILTER HMM 961
2 HMM PROBABILITY_FILTER 1141
3 PROBABILITY_FILTER EXECUTED 73
Signal Loss Report — M5
from_stage to_stage lost_count
0 TREND_PULLBACK LABEL_FILTER 12610
1 LABEL_FILTER HMM 2684
2 HMM PROBABILITY_FILTER 2216
3 PROBABILITY_FILTER EXECUTED 575
Top rejection reasons — M15
SESSION            4140
LOW_PROBABILITY    1141
UNKNOWN            1034

Top rejection reasons — M5
SESSION            12610
UNKNOWN             3259
LOW_PROBABILITY     2216

Probability Drift (IS -> OOS)
timeframe is_median oos_median difference is_mean oos_mean is_std oos_std confidence_collapse
0 M15 0.000053 0.000052 0.000002 0.450516 0.449410 0.497459 0.497344 False
1 M5 0.999903 0.999902 0.000001 0.828328 0.839823 0.376974 0.366643 False
XGBoost Observability — M15
  Confusion Matrix (rows=true[down,neutral,up], cols=pred): [[20825, 0, 0], [0, 50923, 0], [0, 0, 20934]]
  Precision (macro): 1.000
  Recall (macro)   : 1.000
  F1 (macro)       : 1.000
  ROC AUC          : 1.000
  PR AUC           : 1.000
  Brier Score      : 0.0000
  Calibration Error: 0.0001

XGBoost Observability — M5
  Confusion Matrix (rows=true[down,neutral,up], cols=pred): [[114490, 0, 0], [0, 47682, 0], [0, 0, 115685]]
  Precision (macro): 1.000
  Recall (macro)   : 1.000
  F1 (macro)       : 1.000
  ROC AUC          : 1.000
  PR AUC           : 1.000
  Brier Score      : 0.0000
  Calibration Error: 0.0001

HMM Holding Time — M15
  State 0: avg_holding_minutes=246.6
  State 1: avg_holding_minutes=251.68141592920355
  State 2: avg_holding_minutes=413.15028901734104

HMM Holding Time — M5
  State 0: avg_holding_minutes=116.29470672389127
  State 1: avg_holding_minutes=90.22304832713755
  State 2: avg_holding_minutes=162.05240174672488
WARNING: M15 signal loss 93.9% (> 10%) between Generated and Executed
WARNING: M5 signal loss 93.7% (> 10%) between Generated and Executed

Strategy Tester <-> Explorer Consistency
M15 M5
Generated 6725 19296
Loaded 6725 19296
TBM Pass 6725 19296
HMM Pass 1624 4002
XGB Pass 483 1786
Executed 410 1211
Explorer Timeframe Report
M15 M5 pct_diff flag_gt_15pct
Candidate Trades 6.725000e+03 1.929600e+04 65.148217 True
Positive Labels 2.093400e+04 1.156850e+05 81.904309 True
Mean Probability 4.505156e-01 8.283276e-01 45.611422 True
Median Probability 5.327006e-05 9.999033e-01 99.994672 True
Threshold 1.500000e-01 1.500000e-01 0.000000 False
Accepted Trades 4.830000e+02 1.786000e+03 72.956327 True
Rejected Trades 5.964000e+03 1.717100e+04 65.267020 True
OOS Trades 6.500000e+01 4.990000e+02 86.973948 True
Win Rate 5.440210e-01 5.858824e-01 7.145006 False
PF 2.542452e+00 2.800003e+00 9.198221 False
Brier Score 3.065726e-09 4.194030e-09 26.902605 True
Calibration Error 5.532333e-05 6.473793e-05 14.542629 False
Likely Root Cause Ranking
issue evidence timeframe rank
0 Signal generation collapse signal loss 88.7% > 20% (generated 6725 -> exe... M15 1
1 Signal generation collapse signal loss 89.0% > 20% (generated 19296 -> ex... M5 1
All Phase 1-16 traceability reports exported to reports/.