Files
GoldRegime_X/notebooks/Strategy_Tester.ipynb
T
Luke Mosoti 2dff0baa02 Add scripts for generating strategy reports and smoke testing Explorer integration
- Implemented `generate_bridge_csv.py` to generate reports for strategy winners from the Strategy Tester notebook.
- Added `smoke_test_explorer.py` to perform smoke tests on the Explorer notebook integration, validating core logic and data processing.
2026-06-19 11:07:05 +03:00

218 KiB
Raw Blame History

Strategy Tester (No TBM / CPCV / HMM / XGBoost)

Goal:

  1. Compare multiple raw strategy families under one unified backtester.
  2. Include realistic friction checks (spread cap, slippage).
  3. Rank by robustness (plateau stability) and profit-per-trade.
  4. Export top candidates for later wiring into GoldRegimeX_Explorer.
In [1]:
# Imports + Config
import os
import math
import json
import time
import itertools
from pathlib import Path

import numpy as np
import pandas as pd

try:
    from joblib import Parallel, delayed
    JOBLIB_OK = True
except Exception:
    JOBLIB_OK = False

np.random.seed(42)

# -----------------------------
# Runtime controls
# -----------------------------
N_JOBS = 6  # lower to reduce memory pressure under loky
QUICK_MODE = False
RESEARCH_YEARS = 5  # enforced always, regardless of QUICK_MODE

# -----------------------------
# Data paths
# -----------------------------
M5_PATH = Path("data/raw/XAU_5m_data.csv")
M15_PATH = Path("data/raw/XAU_15m_data.csv")

# -----------------------------
# Core assumptions
# -----------------------------
TIMEFRAMES = ["M15", "M5"]
INITIAL_BALANCE_CENTS = 1500.0
PIP_SIZE_PRICE = 0.10
PIP_VALUE_CENTS_PER_1LOT = 100.0
SLIPPAGE_PIPS = 0.30
SPREAD_CAP_POINTS = 40.0
COMMISSION_CENTS_PER_TRADE = 0.0

# Split position support
POSITION_A = 0.01
POSITION_B = 0.02

# M5 Grids (Tighter targets, wider stops to survive noise and high friction)
M5_ADX_GRID = [20, 25]
M5_PULLBACK_RSI_GRID = [30, 35]
M5_CONFIRMATION_GRID = [1, 2]
M5_ATR_STOP_GRID = [1.0, 1.5, 2.0, 2.5, 3.0]  # Extended upward to allow M5 breathing room
M5_LEG_A_TARGET_GRID = [0.8, 1.0]
M5_ENTRY_TARGET_GRID = [1.0, 1.5]

# Exit modes
EXIT_MODELS = [
    "fixed_tp",
    "mr_exit",
    "fixed_tp_plus_mr",
    "partial_tp_plus_mr",
    "partial_tp_mr_time_stop",
]

# ATR multiple grids
LEG_A_ATR_TARGET_GRID = [1.0, 1.5, 2.0]  # Leg A target (ATR multiple)
ENTRY_ATR_TARGET_GRID = [2.0, 2.5, 3.0]  # Leg B (fixed TP) ATR multiple
ATR_TARGET_GRID = ENTRY_ATR_TARGET_GRID  # keep existing name used by strategies

# Leg C constants (Scale-in removed from grid search to save compute)
LEG_C_ATR_TARGET = 0.5
LEG_C_ATR_STOP = 0.5

TIME_STOP_GRID_BY_TF = {
    "M15": [120, 180, 240],
    "M5": [30, 60, 90],
}
TRAIL_MULT_GRID = [1.5, 2.0, 2.5]

# Session filter options
SESSION_FILTER_VALUES = [None, "London", "NY", "London_NY"]

# -----------------------------
# Strategy A grids (Loosened for Frequency)
# -----------------------------
ADX_GRID = [18, 22, 25]  # Lowered from 25+ to catch budding trends earlier
PULLBACK_RSI_GRID = [25, 30, 35, 40]  # Don't wait for extreme oversold conditions
CONFIRMATION_GRID = [1, 2]  # Get in faster, less lag
ATR_STOP_GRID = [1.0, 1.5, 2.0]

# -----------------------------
# Strategy B grids (Loosened for Frequency)
# -----------------------------
ATR_EXPANSION_GRID = [1.1, 1.25, 1.4]  # 1.5+ is too rare; 1.1+ catches standard volatility cycles
BREAKOUT_LOOKBACK_GRID = [10, 20, 30]
BREAKOUT_BUFFER_GRID = [0.0, 0.25]

if QUICK_MODE:
    # Stronger quick cut to keep runtime practical
    ADX_GRID = ADX_GRID[:2]
    PULLBACK_RSI_GRID = PULLBACK_RSI_GRID[:2]
    CONFIRMATION_GRID = CONFIRMATION_GRID[:2]
    ATR_STOP_GRID = ATR_STOP_GRID[:1]
    LEG_A_ATR_TARGET_GRID = LEG_A_ATR_TARGET_GRID[:2]
    ENTRY_ATR_TARGET_GRID = ENTRY_ATR_TARGET_GRID[:2]
    ATR_TARGET_GRID = ENTRY_ATR_TARGET_GRID

    ATR_EXPANSION_GRID = ATR_EXPANSION_GRID[:3]
    BREAKOUT_LOOKBACK_GRID = BREAKOUT_LOOKBACK_GRID[:2]
    BREAKOUT_BUFFER_GRID = BREAKOUT_BUFFER_GRID[:2]

    SESSION_FILTER_VALUES = [None, "London_NY"]
    EXIT_MODELS = ["fixed_tp", "mr_exit", "fixed_tp_plus_mr"]

    TIME_STOP_GRID_BY_TF["M15"] = TIME_STOP_GRID_BY_TF["M15"][:2]
    TIME_STOP_GRID_BY_TF["M5"] = TIME_STOP_GRID_BY_TF["M5"][:2]
    TRAIL_MULT_GRID = TRAIL_MULT_GRID[:2]

print("JOBLIB_OK:", JOBLIB_OK)
print("N_JOBS:", N_JOBS)
print("QUICK_MODE:", QUICK_MODE)
print("RESEARCH_YEARS (enforced):", RESEARCH_YEARS)
JOBLIB_OK: True
N_JOBS: 6
QUICK_MODE: False
RESEARCH_YEARS (enforced): 5
In [2]:
# Optional exploratory speed cap (applies to QUICK_MODE True/False)
ENABLE_ENTRY_CAP = False

# Hard cap per (timeframe, strategy, exit_model) on ENTRY parameter combos.
# This is the biggest speed lever for QUICK_MODE=False.
if QUICK_MODE:
    ENTRY_CAP_BY_TF = {"M15": 80, "M5": 40}
else:
    ENTRY_CAP_BY_TF = {"M15": 200, "M5": 80}

# Seed for deterministic subset selection
ENTRY_CAP_SEED_BASE = 42

print("ENABLE_ENTRY_CAP:", ENABLE_ENTRY_CAP)
print("ENTRY_CAP_BY_TF:", ENTRY_CAP_BY_TF)
print("ENTRY_CAP_SEED_BASE:", ENTRY_CAP_SEED_BASE)
ENABLE_ENTRY_CAP: False
ENTRY_CAP_BY_TF: {'M15': 200, 'M5': 80}
ENTRY_CAP_SEED_BASE: 42
In [3]:
# Data loading, strict 5-year reduction, indicators, and rule-based regimes

def resolve_path(path: Path) -> Path:
    if path.exists():
        return path
    alt = Path.cwd().parent / path
    if alt.exists():
        return alt
    raise FileNotFoundError(f"Path not found: {path}")

def _normalize_ohlc(df: pd.DataFrame) -> pd.DataFrame:
    cols = {c.lower(): c for c in df.columns}
    rename = {}
    for key in ["open", "high", "low", "close", "volume", "spread"]:
        if key in cols:
            rename[cols[key]] = key
    out = df.rename(columns=rename)
    missing = [c for c in ["open", "high", "low", "close"] 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:
    path = resolve_path(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_ohlc(df)
    if "spread" not in df.columns:
        df["spread"] = SPREAD_CAP_POINTS
    return df

def load_recent_years(df: pd.DataFrame, years: int) -> pd.DataFrame:
    last_date = df.index.max()
    start_date = last_date - pd.DateOffset(years=int(years))
    return df.loc[df.index >= start_date].copy()

def enforce_recent_window(df_full: pd.DataFrame, df_trim: pd.DataFrame, years: int, label: str):
    last_date = df_full.index.max()
    target_start = last_date - pd.DateOffset(years=int(years))
    observed_start = df_trim.index.min()
    if observed_start < target_start:
        raise RuntimeError(f"{label} window enforcement failed: observed_start={observed_start}, target_start={target_start}")
    print(f"{label} enforced window: {observed_start} -> {last_date}")

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)
    atr_v = atr(high, low, close, period=period).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 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_tf: str, m5_df: pd.DataFrame, m15_df: pd.DataFrame) -> pd.DataFrame:
    exec_tf = exec_tf.upper()
    if exec_tf not in ("M5", "M15"):
        raise ValueError(f"Unsupported timeframe: {exec_tf}")

    exec_df = m5_df.copy() if exec_tf == "M5" else m15_df.copy()
    trend_df = m15_df.copy()

    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)

    for lb in sorted(set(BREAKOUT_LOOKBACK_GRID)):
        exec_df[f"roll_high_{lb}"] = exec_df["high"].rolling(lb, min_periods=lb).max().shift(1)
        exec_df[f"roll_low_{lb}"] = exec_df["low"].rolling(lb, min_periods=lb).min().shift(1)

    trend_df["m15_ema50"] = ema(trend_df["close"], period=50)
    trend_df["m15_ema200"] = ema(trend_df["close"], period=200)
    trend_df["m15_adx14"] = adx(trend_df["high"], trend_df["low"], trend_df["close"], period=14)

    if exec_tf == "M5":
        ex = exec_df.reset_index().rename(columns={exec_df.index.name or "index": "time"})
        tr = trend_df.reset_index().rename(columns={trend_df.index.name or "index": "time"})
        merged = pd.merge_asof(
            ex.sort_values("time"),
            tr[["time", "m15_ema50", "m15_ema200", "m15_adx14"]].sort_values("time"),
            on="time",
            direction="backward",
        ).set_index("time")
    else:
        merged = exec_df.copy()
        merged["m15_ema50"] = trend_df["m15_ema50"].reindex(merged.index)
        merged["m15_ema200"] = trend_df["m15_ema200"].reindex(merged.index)
        merged["m15_adx14"] = trend_df["m15_adx14"].reindex(merged.index)

    merged = add_session_features(merged)
    merged["spread"] = merged["spread"].fillna(SPREAD_CAP_POINTS)

        # Rule-based regime classification
    is_trend = (merged["m15_adx14"] > 20.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"] = np.where(is_shock, 2, np.where(is_trend, 1, 3)).astype(np.int32)
    
    required = [
        "open", "high", "low", "close", "spread",
        "rsi5", "atr14", "atr100", "atr_expansion",
        "m15_ema50", "m15_ema200", "m15_adx14",
        "session", "regime_str", "regime_code",
        "session_mask_none", "session_mask_london", "session_mask_ny", "session_mask_london_ny",
    ]
    merged = merged.dropna(subset=[c for c in required if c in merged.columns]).copy()
    return merged

m5_raw_full = read_xau_raw(M5_PATH)
m15_raw_full = read_xau_raw(M15_PATH)

m5_raw = load_recent_years(m5_raw_full, years=RESEARCH_YEARS)
m15_raw = load_recent_years(m15_raw_full, years=RESEARCH_YEARS)

enforce_recent_window(m5_raw_full, m5_raw, years=RESEARCH_YEARS, label="M5")
enforce_recent_window(m15_raw_full, m15_raw, years=RESEARCH_YEARS, label="M15")

FEATURES_BY_TF = {
    "M5": build_features("M5", m5_raw, m15_raw),
    "M15": build_features("M15", m5_raw, m15_raw),
}

print("M5 full rows:", len(m5_raw_full), "| recent rows:", len(m5_raw))
print("M15 full rows:", len(m15_raw_full), "| recent rows:", len(m15_raw))
for tf in TIMEFRAMES:
    print(tf, "feature rows:", len(FEATURES_BY_TF[tf]))
display(FEATURES_BY_TF["M5"].head(3))
M5 enforced window: 2021-05-31 01:05:00 -> 2026-05-29 23:50:00
M15 enforced window: 2021-05-31 01:00:00 -> 2026-05-29 23:30:00
M5 full rows: 1468386 | recent rows: 347571
M15 full rows: 502563 | recent rows: 116101
M15 feature rows: 115902
M5 feature rows: 346980
open high low close volume spread rsi5 atr14 atr100 atr_expansion ... m15_ema50 m15_ema200 m15_adx14 session session_mask_none session_mask_london session_mask_ny session_mask_london_ny regime_str regime_code
time
2021-06-02 09:00:00 1898.52 1898.52 1895.16 1895.64 944 40.0 26.791187 1.146459 1.004396 1.141442 ... 1899.620462 1903.612551 17.554024 LONDON True True False True MR 3
2021-06-02 09:05:00 1895.62 1897.71 1895.58 1897.14 600 40.0 44.362487 1.216712 1.015652 1.197962 ... 1899.620462 1903.612551 17.554024 LONDON True True False True MR 3
2021-06-02 09:10:00 1897.14 1898.45 1896.97 1898.02 549 40.0 52.689668 1.235519 1.020295 1.210942 ... 1899.620462 1903.612551 17.554024 LONDON True True False True MR 3

3 rows × 26 columns

In [4]:
# Legacy cell disabled intentionally.
# Data loading and feature building are now centralized in the previous cell
# and strictly enforced to RESEARCH_YEARS only.

print("Legacy research-frame cell disabled. Using FEATURES_BY_TF from strict recent-window pipeline.")
Legacy research-frame cell disabled. Using FEATURES_BY_TF from strict recent-window pipeline.
In [5]:
# Cell 5: Strategy Definitions & Regime-Based Signal Router (With Macro Filter)
class BaseStrategy:
    name = "base"
    param_grid = {}
    param_cols = []

    def iter_param_dicts(self):
        keys = list(self.param_cols)
        vals = [self.param_grid[k] for k in keys]
        for combo in itertools.product(*vals):
            yield dict(zip(keys, combo))

    def generate_signals(self, df: pd.DataFrame, params: dict) -> pd.Series:
        raise NotImplementedError

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(BaseStrategy):
    name = "trend_pullback"
    param_grid = {
        "adx_threshold": ADX_GRID,
        "pullback_rsi": PULLBACK_RSI_GRID,
        "confirmation_bars": CONFIRMATION_GRID,
        "atr_stop": ATR_STOP_GRID,
        "atr_target": ATR_TARGET_GRID,
        "session_filter": SESSION_FILTER_VALUES,
    }
    param_cols = list(param_grid.keys())

    def generate_signals(self, df: pd.DataFrame, params: dict) -> pd.Series:
        adx_threshold = float(params["adx_threshold"])
        pullback_rsi = float(params["pullback_rsi"])
        confirmation_bars = int(params["confirmation_bars"])
        session_col = session_col_from_value(params["session_filter"])

        trend_up_raw = (df["m15_ema50"] > df["m15_ema200"]) & (df["m15_adx14"] > adx_threshold)
        trend_dn_raw = (df["m15_ema50"] < df["m15_ema200"]) & (df["m15_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

class VolatilityExpansionStrategy(BaseStrategy):
    name = "volatility_expansion"
    param_grid = {
        "atr_expansion_threshold": ATR_EXPANSION_GRID,
        "breakout_lookback": BREAKOUT_LOOKBACK_GRID,
        "breakout_buffer": BREAKOUT_BUFFER_GRID,
        "atr_stop": ATR_STOP_GRID,
        "atr_target": ATR_TARGET_GRID,
        "session_filter": SESSION_FILTER_VALUES,
    }
    param_cols = list(param_grid.keys())

    def generate_signals(self, df: pd.DataFrame, params: dict) -> pd.Series:
        thr = float(params["atr_expansion_threshold"])
        lb = int(params["breakout_lookback"])
        buf_mult = float(params["breakout_buffer"])
        session_col = session_col_from_value(params["session_filter"])

        high_col = f"roll_high_{lb}"
        low_col = f"roll_low_{lb}"
        breakout_buffer = buf_mult * df["atr14"]
        is_expansion = df["atr_expansion"] > thr

        # MACRO TREND FILTER: Ensures entries align with higher timeframe momentum
        macro_up = df["m15_ema50"] > df["m15_ema200"]
        macro_dn = df["m15_ema50"] < df["m15_ema200"]

        long_cond = is_expansion & (df["close"] > (df[high_col] + breakout_buffer)) & macro_up & df[session_col].astype(bool)
        short_cond = is_expansion & (df["close"] < (df[low_col] - breakout_buffer)) & macro_dn & 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

STRATEGIES = {
    "trend_pullback": TrendPullbackStrategy(),
    "volatility_expansion": VolatilityExpansionStrategy(),
}

def generate_routed_signals(df: pd.DataFrame, params: dict, strategy_name: str) -> pd.Series:
    raw_signals = STRATEGIES[strategy_name].generate_signals(df, params)
    routed = pd.Series(0, index=df.index, dtype=np.int8)
    if strategy_name == "trend_pullback":
        mask = df["regime_code"] == 1
        routed.loc[mask] = raw_signals.loc[mask]
    elif strategy_name == "volatility_expansion":
        mask = df["regime_code"] == 2
        routed.loc[mask] = raw_signals.loc[mask]
    return routed
In [6]:
# Cell 6: Numba Backtest Engine with Early Eject, Pyramiding (Scale-In), and Asymmetric Guard

from numba import njit
import numpy as np
import pandas as pd
import math

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 = pd.Series(eq).rolling(window=window, min_periods=1).mean().to_numpy()
    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
    }

@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):
        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

                    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

                    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 _safe_float(value, default):
    return float(default) if value is None else float(value)

def run_backtest(timeframe: str, df: pd.DataFrame, signals: pd.Series, entry_params: dict, exit_model: str, exit_params: dict):
    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[exit_model])
    leg_a_atr_target = _safe_float(exit_params.get("leg_a_atr_target", -1.0), -1.0) if exit_params else -1.0
    time_stop_minutes = _safe_float(exit_params.get("time_stop_minutes", -1.0), -1.0) if exit_params else -1.0
    trail_mult = _safe_float(exit_params.get("trail_mult", 0.0), 0.0) if exit_params else 0.0

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

    sig = signals.reindex(idx).fillna(0).astype(np.int8).to_numpy()
    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(SPREAD_CAP_POINTS).to_numpy(dtype=np.float64)
    atr14 = df["atr14"].to_numpy(dtype=np.float64)
    regime_code = df["regime_code"].fillna(3).to_numpy(dtype=np.int32)
    
    is_m5 = 1 if timeframe == "M5" else 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,
        float(entry_params["atr_stop"]), float(entry_params["atr_target"]),
        leg_a_atr_target, exit_mode_code, time_stop_minutes, trail_mult,
        float(INITIAL_BALANCE_CENTS), float(PIP_SIZE_PRICE), float(PIP_VALUE_CENTS_PER_1LOT),
        float(SLIPPAGE_PIPS), float(SPREAD_CAP_POINTS), float(COMMISSION_CENTS_PER_TRADE),
        int(is_m5)
    )

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

    reasons = {1: "STOP", 2: "PROFIT_TARGET", 3: "FIXED_TP", 4: "MR_EXIT", 5: "TIME_STOP", 6: "EOD_CLOSE", 7: "REGIME_SHIFT"}
    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]],
            "leg": np.where(trade_leg[:trade_count] == 0, "A", np.where(trade_leg[:trade_count] == 1, "B", "C_SCALE")),
            "side": np.where(trade_side[:trade_count] == 1, "BUY", "SELL"),
            "entry_price": trade_entry_px[:trade_count],
            "exit_price": trade_exit_px[:trade_count],
            "move_pips": trade_move_pips[:trade_count],
            "pnl_cents": trade_pnl_cents[:trade_count],
            "exit_reason": [reasons[int(x)] for x in trade_reason[:trade_count]],
            "entry_regime": [regime_map[int(x)] for x in trade_entry_regime[:trade_count]],
            "exit_model": exit_model,
        }
    )

    return trades_df, compute_metrics(trades_df, eq[:eq_count].tolist(), INITIAL_BALANCE_CENTS, float(ending_balance))
In [7]:
# Vectorized Plateau Stability Engine & Group Experiment Runner

import zlib

def _is_numeric_grid(values: list) -> bool:
    return all(isinstance(v, (int, float, np.integer, np.floating)) for v in values)

def build_step_map(param_grid: dict) -> dict:
    step_map = {}
    for key, vals in param_grid.items():
        uniq = sorted(set(vals), key=lambda x: str(x))
        if _is_numeric_grid(uniq) and len(uniq) > 1:
            diffs = np.diff(np.array(uniq, dtype=float))
            diffs = diffs[diffs > 0]
            step_map[key] = float(np.min(diffs)) if len(diffs) > 0 else 1.0
        else:
            step_map[key] = 1.0 if _is_numeric_grid(uniq) else None
    return step_map

def add_parameter_stability_score(
    results_df: pd.DataFrame,
    param_cols: list[str],
    step_map: dict,
    perf_col: str = "profit_per_trade",
    block_size: int = 128,  # lower to reduce memory pressure
) -> pd.DataFrame:
    if results_df.empty:
        out = results_df.copy()
        out["parameter_stability_score"] = np.nan
        return out

    out = results_df.copy()
    M = len(out)

    numeric_cols = [c for c in param_cols if step_map.get(c, None) is not None]
    cat_cols = [c for c in param_cols if step_map.get(c, None) is None]

    num_mat = out[numeric_cols].astype(float).to_numpy() if numeric_cols else np.empty((M, 0), dtype=float)
    step_sizes = np.array([float(step_map[c]) for c in numeric_cols], dtype=float) if numeric_cols else np.empty((0,), dtype=float)
    cat_mat = np.column_stack([pd.factorize(out[c].astype(str))[0] for c in cat_cols]).astype(np.int32) if cat_cols else np.empty((M, 0), dtype=np.int32)

    perf = out[perf_col].astype(float).to_numpy()
    perf2 = perf * perf
    scores = np.empty(M, dtype=float)

    for start in range(0, M, block_size):
        end = min(M, start + block_size)

        if num_mat.shape[1] > 0:
            diffs = np.abs(num_mat[start:end, None, :] - num_mat[None, :, :])
            num_mask = np.all(diffs <= step_sizes, axis=2)
        else:
            num_mask = np.ones((end - start, M), dtype=bool)

        if cat_mat.shape[1] > 0:
            cat_mask = np.all(cat_mat[start:end, None, :] == cat_mat[None, :, :], axis=2)
        else:
            cat_mask = np.ones((end - start, M), dtype=bool)

        mask = num_mask & cat_mask
        counts = mask.sum(axis=1).astype(np.float64)
        sums = mask @ perf
        sums2 = mask @ perf2

        means = np.where(counts > 0, sums / counts, -1e9)
        vars_ = np.maximum(np.where(counts > 0, sums2 / counts - means * means, 0.0), 0.0)
        scores[start:end] = means - np.sqrt(vars_)

    out["parameter_stability_score"] = scores
    return out

def _cap_entry_combos(
    entry_combos: list[dict],
    timeframe: str,
    strategy_name: str,
    exit_model: str,
) -> tuple[list[dict], int, bool]:
    enable = bool(globals().get("ENABLE_ENTRY_CAP", False))
    cap_map = globals().get("ENTRY_CAP_BY_TF", {})
    cap_val = cap_map.get(timeframe, None) if isinstance(cap_map, dict) else None

    original_n = len(entry_combos)
    if (not enable) or (cap_val is None):
        return entry_combos, original_n, False

    cap_n = int(cap_val)
    if cap_n <= 0 or original_n <= cap_n:
        return entry_combos, original_n, False

    seed_base = int(globals().get("ENTRY_CAP_SEED_BASE", 42))
    seed_key = f"{seed_base}|{timeframe}|{strategy_name}|{exit_model}"
    seed = zlib.crc32(seed_key.encode("utf-8")) & 0xFFFFFFFF

    rng = np.random.default_rng(seed)
    picked_idx = np.sort(rng.choice(original_n, size=cap_n, replace=False))
    capped = [entry_combos[i] for i in picked_idx.tolist()]
    return capped, original_n, True

def get_exit_grid_for_mode(timeframe: str, exit_model: str):
    ts_grid = TIME_STOP_GRID_BY_TF[timeframe]
    tr_grid = TRAIL_MULT_GRID

    if exit_model == "fixed_tp":
        cfg = [{"leg_a_atr_target": p} for p in LEG_A_ATR_TARGET_GRID]
        grid_map = {"leg_a_atr_target": LEG_A_ATR_TARGET_GRID}
    elif exit_model == "mr_exit":
        cfg = [{"leg_a_atr_target": None}]
        grid_map = {}
    elif exit_model == "fixed_tp_plus_mr":
        cfg = [{"leg_a_atr_target": p} for p in LEG_A_ATR_TARGET_GRID]
        grid_map = {"leg_a_atr_target": LEG_A_ATR_TARGET_GRID}
    elif exit_model == "partial_tp_plus_mr":
        cfg = [{"leg_a_atr_target": p} for p in LEG_A_ATR_TARGET_GRID]
        grid_map = {"leg_a_atr_target": LEG_A_ATR_TARGET_GRID}
    elif exit_model == "partial_tp_mr_time_stop":
        cfg = [
            {
                "leg_a_atr_target": p,
                "time_stop_minutes": t,
                "trail_mult": m,
            }
            for p, t, m in itertools.product(
                LEG_A_ATR_TARGET_GRID,
                ts_grid,
                tr_grid,
            )
        ]
        grid_map = {
            "leg_a_atr_target": LEG_A_ATR_TARGET_GRID,
            "time_stop_minutes": ts_grid,
            "trail_mult": tr_grid,
        }
    else:
        raise ValueError(f"Unsupported exit model: {exit_model}")

    return cfg, grid_map

def run_group(timeframe: str, strategy_name: str, exit_model: str) -> pd.DataFrame:
    df = FEATURES_BY_TF[timeframe]
    strategy = STRATEGIES[strategy_name]

    # Per-TF grid override: rebind the per-strategy param_grid and module-level exit grids
    # for M5 only, so M15 runs are byte-identical to before this change.
    _restore = False
    _orig_grid = dict(strategy.param_grid)
    _orig_legA = list(LEG_A_ATR_TARGET_GRID)
    _orig_entry = list(ENTRY_ATR_TARGET_GRID)
    _orig_atrT = list(ATR_TARGET_GRID)
    if timeframe == "M5":
        if strategy_name == "trend_pullback":
            strategy.param_grid = {**_orig_grid,
                "adx_threshold":    M5_ADX_GRID,
                "pullback_rsi":     M5_PULLBACK_RSI_GRID,
                "confirmation_bars":M5_CONFIRMATION_GRID,
                "atr_stop":         M5_ATR_STOP_GRID,
                "atr_target":       M5_ENTRY_TARGET_GRID,
                "session_filter":   _orig_grid.get("session_filter", SESSION_FILTER_VALUES),
            }
        elif strategy_name == "volatility_expansion":
            strategy.param_grid = {**_orig_grid,
                "atr_stop":         M5_ATR_STOP_GRID,
                "atr_target":       M5_ENTRY_TARGET_GRID,
                "session_filter":   _orig_grid.get("session_filter", SESSION_FILTER_VALUES),
            }
        LEG_A_ATR_TARGET_GRID[:] = M5_LEG_A_TARGET_GRID
        ENTRY_ATR_TARGET_GRID[:] = M5_ENTRY_TARGET_GRID
        ATR_TARGET_GRID[:]       = M5_LEG_A_TARGET_GRID
        _restore = True

    t0 = time.time()
    try:
        entry_combos_full = list(strategy.iter_param_dicts())
        entry_combos, full_n, was_capped = _cap_entry_combos(
            entry_combos_full, timeframe, strategy_name, exit_model
        )

        exit_cfgs, exit_grid_map = get_exit_grid_for_mode(timeframe, exit_model)

        rows = []
        total = len(entry_combos) * len(exit_cfgs)
        done = 0

        if was_capped:
            print(
                f"[{timeframe}][{strategy_name}][{exit_model}] entry combos capped: "
                f"{len(entry_combos)}/{full_n}"
            )
        else:
            print(
                f"[{timeframe}][{strategy_name}][{exit_model}] entry combos used: "
                f"{len(entry_combos)}/{full_n}"
            )

        for i, entry_params in enumerate(entry_combos, 1):
            signals = generate_routed_signals(df, entry_params, strategy_name)

            for exit_cfg in exit_cfgs:
                _, metrics = run_backtest(
                    timeframe=timeframe,
                    df=df,
                    signals=signals,
                    entry_params=entry_params,
                    exit_model=exit_model,
                    exit_params=exit_cfg,
                )

                combined_params = {
                    **entry_params,
                    **{k: v for k, v in exit_cfg.items() if v is not None},
                }

                row = {
                    "timeframe": timeframe,
                    "strategy_name": strategy_name,
                    "exit_model": exit_model,
                    "parameter_set": json.dumps(combined_params, sort_keys=True),
                    **metrics,
                }
                for k, v in combined_params.items():
                    row[k] = v
                rows.append(row)

                done += 1

            if i % 50 == 0 or i == len(entry_combos):
                print(f"[{timeframe}][{strategy_name}][{exit_model}] {done}/{total}")

        res = pd.DataFrame(rows)

        param_cols = list(strategy.param_cols) + list(exit_grid_map.keys())
        full_grid_map = {**strategy.param_grid, **exit_grid_map}
        step_map = build_step_map(full_grid_map)

        res = add_parameter_stability_score(
            res, param_cols=param_cols, step_map=step_map, perf_col="profit_per_trade"
        )

        res["robust_score"] = (
            0.45 * res["parameter_stability_score"].astype(float)
            + 0.35 * res["profit_per_trade"].astype(float)
            + 0.20 * res["profit_factor"].astype(float)
        )

        res = res.sort_values(
            ["robust_score", "profit_per_trade", "profit_factor", "sharpe"],
            ascending=False,
        ).reset_index(drop=True)

        return res
    finally:
        if _restore:
            strategy.param_grid = _orig_grid
            LEG_A_ATR_TARGET_GRID[:] = _orig_legA
            ENTRY_ATR_TARGET_GRID[:] = _orig_entry
            ATR_TARGET_GRID[:]       = _orig_atrT
        elapsed = time.time() - t0
        print(f"[{timeframe}][{strategy_name}][{exit_model}] done in {elapsed/60:.2f} minutes")
In [8]:
# Concurrent execution across timeframe + strategy + exit model

group_tasks = [
    (tf, strategy_name, exit_model)
    for tf in TIMEFRAMES
    for strategy_name in STRATEGIES.keys()
    for exit_model in EXIT_MODELS
]

print("Total groups:", len(group_tasks))
for t in group_tasks:
    print("Task:", t)

def _run_task(task):
    tf, strategy_name, exit_model = task
    return run_group(tf, strategy_name, exit_model)

if JOBLIB_OK:
    try:
        nj = min(max(1, N_JOBS), len(group_tasks))
        print(f"Running parallel with joblib loky backend... n_jobs={nj}")
        group_results = Parallel(n_jobs=nj, backend="loky", verbose=10, batch_size=1)(
            delayed(_run_task)(task) for task in group_tasks
        )
    except Exception as e:
        print(f"[warn] loky failed: {type(e).__name__}: {e}")
        try:
            nj = min(max(1, N_JOBS), len(group_tasks))
            print(f"Falling back to threading... n_jobs={nj}")
            group_results = Parallel(n_jobs=nj, backend="threading", verbose=10, batch_size=1)(
                delayed(_run_task)(task) for task in group_tasks
            )
        except Exception as e2:
            print(f"[warn] threading failed: {type(e2).__name__}: {e2}")
            print("Falling back to sequential execution.")
            group_results = [_run_task(task) for task in group_tasks]
else:
    print("joblib unavailable. Running sequentially.")
    group_results = [_run_task(task) for task in group_tasks]

all_results = pd.concat(group_results, axis=0, ignore_index=True)

# Apply statistical significance filter before sorting
valid_results = all_results[all_results["trade_count"] >= 200]

best_rows = (
    valid_results.sort_values(
        ["robust_score", "profit_per_trade", "profit_factor", "sharpe"],
        ascending=False,
    )
    .groupby(["timeframe", "strategy_name", "exit_model"], as_index=False)
    .head(1)
    .reset_index(drop=True)
)

print("All results shape:", all_results.shape)
print("Best rows shape:", best_rows.shape)
display(best_rows.head(20))
Total groups: 20
Task: ('M15', 'trend_pullback', 'fixed_tp')
Task: ('M15', 'trend_pullback', 'mr_exit')
Task: ('M15', 'trend_pullback', 'fixed_tp_plus_mr')
Task: ('M15', 'trend_pullback', 'partial_tp_plus_mr')
Task: ('M15', 'trend_pullback', 'partial_tp_mr_time_stop')
Task: ('M15', 'volatility_expansion', 'fixed_tp')
Task: ('M15', 'volatility_expansion', 'mr_exit')
Task: ('M15', 'volatility_expansion', 'fixed_tp_plus_mr')
Task: ('M15', 'volatility_expansion', 'partial_tp_plus_mr')
Task: ('M15', 'volatility_expansion', 'partial_tp_mr_time_stop')
Task: ('M5', 'trend_pullback', 'fixed_tp')
Task: ('M5', 'trend_pullback', 'mr_exit')
Task: ('M5', 'trend_pullback', 'fixed_tp_plus_mr')
Task: ('M5', 'trend_pullback', 'partial_tp_plus_mr')
Task: ('M5', 'trend_pullback', 'partial_tp_mr_time_stop')
Task: ('M5', 'volatility_expansion', 'fixed_tp')
Task: ('M5', 'volatility_expansion', 'mr_exit')
Task: ('M5', 'volatility_expansion', 'fixed_tp_plus_mr')
Task: ('M5', 'volatility_expansion', 'partial_tp_plus_mr')
Task: ('M5', 'volatility_expansion', 'partial_tp_mr_time_stop')
Running parallel with joblib loky backend... n_jobs=6
[Parallel(n_jobs=6)]: Using backend LokyBackend with 6 concurrent workers.
[Parallel(n_jobs=6)]: Done   1 tasks      | elapsed:   59.6s
[Parallel(n_jobs=6)]: Done   6 tasks      | elapsed:  1.9min
[Parallel(n_jobs=6)]: Done  12 out of  20 | elapsed:  3.1min remaining:  2.1min
[Parallel(n_jobs=6)]: Done  15 out of  20 | elapsed:  4.7min remaining:  1.6min
[Parallel(n_jobs=6)]: Done  18 out of  20 | elapsed:  7.7min remaining:   51.5s
[Parallel(n_jobs=6)]: Done  20 out of  20 | elapsed: 12.2min finished
All results shape: (81944, 29)
Best rows shape: (20, 29)
timeframe strategy_name exit_model parameter_set profit_factor sharpe sortino calmar max_drawdown expectancy ... atr_target session_filter leg_a_atr_target parameter_stability_score robust_score time_stop_minutes trail_mult atr_expansion_threshold breakout_lookback breakout_buffer
0 M5 trend_pullback fixed_tp_plus_mr {"adx_threshold": 20, "atr_stop": 3.0, "atr_ta... 1.621118 12.234261 10.474211 165.250526 29.491374 11.302099 ... 1.0 NY 1.0 5.749930 6.867426 NaN NaN NaN NaN NaN
1 M5 trend_pullback fixed_tp {"adx_threshold": 20, "atr_stop": 3.0, "atr_ta... 1.578148 11.642483 9.814253 173.968348 27.588195 11.161565 ... 1.0 NY 1.0 5.661111 6.769677 NaN NaN NaN NaN NaN
2 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.370537 4.188649 6.641625 53.813999 31.410040 12.520740 ... 2.0 NY 1.0 3.229089 6.109456 NaN NaN NaN NaN NaN
3 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.345952 3.477543 4.954720 40.595645 25.366391 11.382811 ... 2.0 NY 1.0 3.248301 5.714910 NaN NaN NaN NaN NaN
4 M15 trend_pullback partial_tp_plus_mr {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.249852 0.595275 10.126766 1.545975 527.653667 5.416597 ... 2.5 London 1.0 2.522877 3.281074 NaN NaN NaN NaN NaN
5 M5 trend_pullback mr_exit {"adx_threshold": 25, "atr_stop": 3.0, "atr_ta... 1.114669 1.193433 4.240885 2.887885 414.372215 5.854498 ... 1.0 London NaN 1.783306 3.074496 NaN NaN NaN NaN NaN
6 M5 trend_pullback partial_tp_mr_time_stop {"adx_threshold": 20, "atr_stop": 1.5, "atr_ta... 1.525299 0.604294 19.243243 1.975642 493.775532 7.338442 ... 1.0 NY 0.8 -2.708451 1.654712 30.0 2.5 NaN NaN NaN
7 M5 trend_pullback partial_tp_plus_mr {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.165881 1.402416 5.676882 3.685171 353.802650 3.736597 ... 1.0 London 0.8 -0.970936 1.104064 NaN NaN NaN NaN NaN
8 M15 trend_pullback mr_exit {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.046140 0.490537 1.717055 1.161348 340.940935 2.251427 ... 2.0 London NaN -1.980422 0.106038 NaN NaN NaN NaN NaN
9 M15 trend_pullback partial_tp_mr_time_stop {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.178037 0.562363 7.819782 1.172619 656.809194 3.512560 ... 2.0 NaN 1.0 -3.036792 0.098447 120.0 2.5 NaN NaN NaN
10 M5 volatility_expansion fixed_tp {"atr_expansion_threshold": 1.1, "atr_stop": 3... 0.794116 -4.976338 -5.393575 -1.123229 1829.784419 -7.354247 ... 1.0 NY 0.8 -19.443453 -11.164717 NaN NaN 1.10 10.0 0.0
11 M5 volatility_expansion fixed_tp_plus_mr {"atr_expansion_threshold": 1.1, "atr_stop": 3... 0.772078 -5.485878 -5.852539 -1.152731 1912.709827 -7.872554 ... 1.0 NY 0.8 -19.665352 -11.450387 NaN NaN 1.10 10.0 0.0
12 M5 volatility_expansion partial_tp_plus_mr {"atr_expansion_threshold": 1.1, "atr_stop": 3... 0.692409 -5.231683 -9.002916 -1.574756 2641.264073 -12.824293 ... 1.0 NaN 0.8 -23.091460 -14.741178 NaN NaN 1.10 10.0 0.0
13 M5 volatility_expansion mr_exit {"atr_expansion_threshold": 1.1, "atr_stop": 3... 0.835816 -2.596615 -5.033858 -2.186216 1034.694642 -11.052442 ... 1.0 NaN NaN -26.502203 -15.627183 NaN NaN 1.10 10.0 0.0
14 M5 volatility_expansion partial_tp_mr_time_stop {"atr_expansion_threshold": 1.1, "atr_stop": 3... 0.589071 -8.814473 -13.093820 -1.080672 3633.204292 -15.031789 ... 1.0 NY 0.8 -25.463303 -16.601798 30.0 2.5 1.10 10.0 0.0
15 M15 volatility_expansion fixed_tp {"atr_expansion_threshold": 1.4, "atr_stop": 1... 0.093356 -23.022329 -28.595623 -1.073064 3207.330550 -44.930435 ... 3.0 NY 1.0 -53.789235 -39.912137 NaN NaN 1.40 20.0 0.0
16 M15 volatility_expansion fixed_tp_plus_mr {"atr_expansion_threshold": 1.25, "atr_stop": ... 0.372743 -10.931327 -19.618601 -1.004495 4519.688383 -41.779180 ... 3.0 NY 1.0 -56.423472 -39.938727 NaN NaN 1.25 10.0 0.0
17 M15 volatility_expansion partial_tp_mr_time_stop {"atr_expansion_threshold": 1.4, "atr_stop": 1... 0.103077 -18.935921 -28.128949 -1.449522 2338.183548 -44.323201 ... 2.0 NY 1.0 -55.583870 -40.505246 180.0 2.5 1.40 20.0 0.0
18 M15 volatility_expansion partial_tp_plus_mr {"atr_expansion_threshold": 1.25, "atr_stop": ... 0.071321 -32.049963 -38.782424 -1.068878 5522.085326 -44.692862 ... 2.0 NY 1.0 -55.597356 -40.647048 NaN NaN 1.25 10.0 0.0
19 M15 volatility_expansion mr_exit {"atr_expansion_threshold": 1.25, "atr_stop": ... 0.080354 -31.006587 -38.713139 -1.158615 5041.408223 -45.209381 ... 2.0 NY NaN -57.016762 -41.464756 NaN NaN 1.25 10.0 0.0

20 rows × 29 columns

In [9]:
# Reporting and leaderboards (use valid_results to exclude low-trade runs)

os.makedirs("reports", exist_ok=True)
leg_c_lot_rule = "A_if_A_hits_first_else_B"
# Required strategy summary (plus useful extras) -- filtered
summary_cols = [
    "timeframe",
    "strategy_name",
    "exit_model",
    "profit_factor",
    "sharpe",
    "sortino",
    "calmar",
    "max_drawdown",
    "expectancy",
    "win_rate",
    "trade_count",
    "parameter_set",
    "profit_per_trade",
    "parameter_stability_score",
    "robust_score",
]
strategy_summary = valid_results[summary_cols].copy()
strategy_summary["leg_c_lot_rule"] = leg_c_lot_rule
strategy_summary.to_csv("reports/strategy_summary.csv", index=False)

# Full output for diagnostics (unfiltered)
all_results.to_csv("reports/strategy_full_results.csv", index=False)

# New required leaderboard format -- filtered
leaderboard = (
    valid_results.sort_values(
        ["profit_per_trade", "robust_score", "profit_factor", "sharpe"],
        ascending=False,
    )
    .copy()
)
leaderboard = leaderboard.assign(leg_c_lot_rule=leg_c_lot_rule)
leaderboard_view = leaderboard[
    [
        "timeframe",
        "strategy_name",
        "exit_model",
        "profit_factor",
        "sharpe",
        "sortino",
        "calmar",
        "expectancy",
        "profit_per_trade",
        "trade_count",
        "max_drawdown",
        "parameter_set",
        "parameter_stability_score",
        "robust_score",
    ]
].copy()

leaderboard_view = leaderboard_view.rename(
    columns={
        "timeframe": "Timeframe",
        "strategy_name": "Strategy",
        "exit_model": "Exit Model",
        "profit_factor": "PF",
        "sharpe": "Sharpe",
        "sortino": "Sortino",
        "calmar": "Calmar",
        "expectancy": "Expectancy",
        "profit_per_trade": "Profit Per Trade",
        "trade_count": "Trade Count",
        "max_drawdown": "Max DD",
        "leg_c_lot_rule": "Leg C Lot Rule",
    },
)

leaderboard_view.to_csv("reports/strategy_leaderboard.csv", index=False)

# Focus comparison requested in objective -- filtered
focus = valid_results[
    (valid_results["strategy_name"].isin(["trend_pullback", "volatility_expansion"]))
    & (
        valid_results["exit_model"].isin(
            ["partial_tp_plus_mr", "partial_tp_mr_time_stop"]
        )
    )
].copy()

focus = focus.sort_values(
    ["timeframe", "profit_per_trade", "robust_score", "profit_factor", "sharpe"],
    ascending=[True, False, False, False, False],
).reset_index(drop=True)
focus["leg_c_lot_rule"] = leg_c_lot_rule
focus.to_csv("reports/strategy_focus_partialtp_mr.csv", index=False)

print("Saved: reports/strategy_summary.csv")
print("Saved: reports/strategy_full_results.csv")
print("Saved: reports/strategy_leaderboard.csv")
print("Saved: reports/strategy_focus_partialtp_mr.csv")

print("\nTop Leaderboard Rows")
display(leaderboard_view.head(30))

print("\nTop Focus Rows (partial TP + MR variants)")
display(
    focus[
        [
            "timeframe",
            "strategy_name",
            "exit_model",
            "profit_factor",
            "sharpe",
            "sortino",
            "calmar",
            "expectancy",
            "profit_per_trade",
            "trade_count",
            "max_drawdown",
            "parameter_set",
            "parameter_stability_score",
            "robust_score",
            "leg_c_lot_rule",
        ]
    ].head(30)
)
Saved: reports/strategy_summary.csv
Saved: reports/strategy_full_results.csv
Saved: reports/strategy_leaderboard.csv
Saved: reports/strategy_focus_partialtp_mr.csv

Top Leaderboard Rows
Timeframe Strategy Exit Model PF Sharpe Sortino Calmar Expectancy Profit Per Trade Trade Count Max DD parameter_set parameter_stability_score robust_score
17 M15 trend_pullback fixed_tp 1.333323 4.278662 7.955413 58.609094 13.857913 13.857913 2299 36.239360 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -0.252988 5.003089
18 M15 trend_pullback fixed_tp 1.332468 4.436246 8.300657 57.832734 13.815454 13.815454 2459 39.161446 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -0.252988 4.988058
25 M15 trend_pullback fixed_tp 1.286177 3.762396 7.611348 48.019762 13.391989 13.391989 2162 40.196617 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -1.115868 4.442291
26 M15 trend_pullback fixed_tp 1.286701 3.914677 7.942149 41.070814 13.371856 13.371856 2316 50.269627 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -1.115868 4.435349
10 M15 trend_pullback fixed_tp 1.384734 4.935053 8.273799 73.654455 13.155333 13.155333 2784 33.149792 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 0.780890 5.232714
14 M15 trend_pullback fixed_tp 1.322699 4.697475 8.552221 68.284990 13.130025 13.130025 2882 36.943924 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 0.544866 5.105238
11 M15 trend_pullback fixed_tp 1.381952 4.723313 7.913530 75.399779 13.088870 13.088870 2605 30.147308 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 0.780890 5.208895
15 M15 trend_pullback fixed_tp 1.318266 4.628392 8.437445 67.233667 13.011026 13.011026 2864 36.949325 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 0.544866 5.062702
6 M15 trend_pullback fixed_tp 1.381456 5.331204 8.724625 85.963167 12.700256 12.700256 3274 32.246865 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.617015 5.449038
7 M15 trend_pullback fixed_tp 1.375666 5.247131 8.597868 84.759702 12.578022 12.578022 3252 32.172308 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.617015 5.405098
0 M15 trend_pullback fixed_tp 1.370537 4.188649 6.641625 53.813999 12.520740 12.520740 2025 31.410040 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 3.229089 6.109456
5 M15 trend_pullback fixed_tp 1.301341 3.568372 6.372474 36.320617 12.412562 12.412562 1796 40.918838 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 2.288479 5.634481
31 M15 trend_pullback fixed_tp 1.265143 3.959253 7.798584 56.230334 12.243872 12.243872 2688 39.019899 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... -0.391266 4.362314
33 M15 trend_pullback fixed_tp 1.260456 3.886657 7.661088 49.898391 12.066509 12.066509 2676 43.140974 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... -0.391266 4.299300
1 M15 trend_pullback fixed_tp 1.357223 4.392425 6.905418 81.682167 11.896903 11.896903 2357 22.886268 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 3.382810 5.957625
3475 M15 trend_pullback fixed_tp_plus_mr 1.310578 4.263791 7.151919 46.223372 11.813573 11.813573 2461 41.931460 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -0.142590 4.332700
2 M15 trend_pullback fixed_tp 1.351545 4.318225 6.799578 80.616706 11.789727 11.789727 2337 22.784849 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 3.382810 5.918978
3464 M15 trend_pullback fixed_tp_plus_mr 1.373633 4.945160 7.509914 60.425570 11.749937 11.749937 2786 36.116414 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 0.993453 4.834258
3 M15 trend_pullback fixed_tp 1.332959 3.324974 4.995290 43.133688 11.668194 11.668194 1357 24.472349 {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 3.229089 5.803550
35 M15 trend_pullback fixed_tp 1.266809 3.130916 5.612771 29.495884 11.588520 11.588520 1728 45.260466 {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... -0.252988 4.195499
56906 M5 trend_pullback fixed_tp_plus_mr 1.661116 13.694720 13.265897 168.202618 11.439824 11.439824 6816 30.904727 {"adx_threshold": 20, "atr_stop": 2.5, "atr_ta... 5.406620 6.769141
21 M15 trend_pullback fixed_tp 1.319938 3.609867 5.793276 35.944975 11.437576 11.437576 1959 41.556502 {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 0.780890 4.618540
56907 M5 trend_pullback fixed_tp_plus_mr 1.661413 13.589031 13.094294 157.205082 11.422626 11.422626 6718 32.542292 {"adx_threshold": 20, "atr_stop": 2.5, "atr_ta... 5.406620 6.763181
4 M15 trend_pullback fixed_tp 1.330536 3.633242 5.721421 48.838477 11.420604 11.420604 1858 28.965524 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 3.229089 5.716408
3456 M15 trend_pullback fixed_tp_plus_mr 1.345952 3.477543 4.954720 40.595645 11.382811 11.382811 1357 25.366391 {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 3.248301 5.714910
3460 M15 trend_pullback fixed_tp_plus_mr 1.381059 5.476502 8.258916 67.028346 11.373871 11.373871 3284 37.150244 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.887004 5.106218
45 M15 trend_pullback fixed_tp 1.237323 2.845330 5.509032 20.645726 11.365493 11.365493 1654 60.701913 {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... -1.115868 3.723247
3467 M15 trend_pullback fixed_tp_plus_mr 1.360013 4.614446 6.948061 60.114298 11.337222 11.337222 2607 32.777714 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 0.993453 4.687084
3478 M15 trend_pullback fixed_tp_plus_mr 1.297340 3.949863 6.530574 43.167814 11.322866 11.322866 2301 40.236637 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -0.142590 4.158306
8 M15 trend_pullback fixed_tp 1.276423 3.576917 6.310785 56.729663 11.315397 11.315397 2084 27.711884 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.428817 5.308641
Top Focus Rows (partial TP + MR variants)
timeframe strategy_name exit_model profit_factor sharpe sortino calmar expectancy profit_per_trade trade_count max_drawdown parameter_set parameter_stability_score robust_score leg_c_lot_rule
0 M15 trend_pullback partial_tp_plus_mr 1.172809 1.420856 3.803287 14.964937 6.346554 6.346554 2337 66.073987 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -2.281026 1.429394 A_if_A_hits_first_else_B
1 M15 trend_pullback partial_tp_plus_mr 1.172809 1.420856 3.803287 14.964937 6.346554 6.346554 2337 66.073987 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -2.281026 1.429394 A_if_A_hits_first_else_B
2 M15 trend_pullback partial_tp_plus_mr 1.172809 1.420856 3.803287 14.964937 6.346554 6.346554 2337 66.073987 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -2.281026 1.429394 A_if_A_hits_first_else_B
3 M15 trend_pullback partial_tp_plus_mr 1.229318 1.001979 8.125460 4.838034 5.754595 5.754595 1718 136.231568 {"adx_threshold": 22, "atr_stop": 1.5, "atr_ta... -5.577406 -0.249861 A_if_A_hits_first_else_B
4 M15 trend_pullback partial_tp_plus_mr 1.229318 1.001979 8.125460 4.838034 5.754595 5.754595 1718 136.231568 {"adx_threshold": 22, "atr_stop": 1.5, "atr_ta... -5.577406 -0.249861 A_if_A_hits_first_else_B
5 M15 trend_pullback partial_tp_plus_mr 1.229318 1.001979 8.125460 4.838034 5.754595 5.754595 1718 136.231568 {"adx_threshold": 22, "atr_stop": 1.5, "atr_ta... -5.577406 -0.249861 A_if_A_hits_first_else_B
6 M15 trend_pullback partial_tp_plus_mr 1.234923 1.132806 8.761147 7.802020 5.696585 5.696585 1974 96.086735 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.199157 A_if_A_hits_first_else_B
7 M15 trend_pullback partial_tp_plus_mr 1.234923 1.132806 8.761147 7.802020 5.696585 5.696585 1974 96.086735 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.199157 A_if_A_hits_first_else_B
8 M15 trend_pullback partial_tp_plus_mr 1.234923 1.132806 8.761147 7.802020 5.696585 5.696585 1974 96.086735 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.199157 A_if_A_hits_first_else_B
9 M15 trend_pullback partial_tp_plus_mr 1.225407 0.567427 10.597769 1.392528 5.583224 5.583224 2090 558.645174 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.232702 A_if_A_hits_first_else_B
10 M15 trend_pullback partial_tp_plus_mr 1.225407 0.567427 10.597769 1.392528 5.583224 5.583224 2090 558.645174 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.232702 A_if_A_hits_first_else_B
11 M15 trend_pullback partial_tp_plus_mr 1.225407 0.567427 10.597769 1.392528 5.583224 5.583224 2090 558.645174 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.232702 A_if_A_hits_first_else_B
12 M15 trend_pullback partial_tp_plus_mr 1.227649 0.682270 11.115989 2.095414 5.579037 5.579037 2525 448.187201 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.055634 A_if_A_hits_first_else_B
13 M15 trend_pullback partial_tp_plus_mr 1.227649 0.682270 11.115989 2.095414 5.579037 5.579037 2525 448.187201 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.055634 A_if_A_hits_first_else_B
14 M15 trend_pullback partial_tp_plus_mr 1.227649 0.682270 11.115989 2.095414 5.579037 5.579037 2525 448.187201 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.055634 A_if_A_hits_first_else_B
15 M15 trend_pullback partial_tp_plus_mr 1.230075 1.120182 8.643558 8.616514 5.578567 5.578567 1994 86.064680 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.156881 A_if_A_hits_first_else_B
16 M15 trend_pullback partial_tp_plus_mr 1.230075 1.120182 8.643558 8.616514 5.578567 5.578567 1994 86.064680 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.156881 A_if_A_hits_first_else_B
17 M15 trend_pullback partial_tp_plus_mr 1.230075 1.120182 8.643558 8.616514 5.578567 5.578567 1994 86.064680 {"adx_threshold": 18, "atr_stop": 1.5, "atr_ta... -4.536961 0.156881 A_if_A_hits_first_else_B
18 M15 trend_pullback partial_tp_plus_mr 1.225834 0.680135 11.064992 2.091295 5.533431 5.533431 2538 447.692266 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.039309 A_if_A_hits_first_else_B
19 M15 trend_pullback partial_tp_plus_mr 1.225834 0.680135 11.064992 2.091295 5.533431 5.533431 2538 447.692266 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.039309 A_if_A_hits_first_else_B
20 M15 trend_pullback partial_tp_plus_mr 1.225834 0.680135 11.064992 2.091295 5.533431 5.533431 2538 447.692266 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.905425 3.039309 A_if_A_hits_first_else_B
21 M15 trend_pullback partial_tp_plus_mr 1.116264 1.041579 2.987987 7.421251 5.498496 5.498496 2069 102.196499 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -3.788433 0.442931 A_if_A_hits_first_else_B
22 M15 trend_pullback partial_tp_plus_mr 1.116264 1.041579 2.987987 7.421251 5.498496 5.498496 2069 102.196499 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -3.788433 0.442931 A_if_A_hits_first_else_B
23 M15 trend_pullback partial_tp_plus_mr 1.116264 1.041579 2.987987 7.421251 5.498496 5.498496 2069 102.196499 {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... -3.788433 0.442931 A_if_A_hits_first_else_B
24 M15 trend_pullback partial_tp_plus_mr 1.218655 0.553667 10.324692 1.327022 5.419408 5.419408 2101 572.016287 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.174016 A_if_A_hits_first_else_B
25 M15 trend_pullback partial_tp_plus_mr 1.218655 0.553667 10.324692 1.327022 5.419408 5.419408 2101 572.016287 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.174016 A_if_A_hits_first_else_B
26 M15 trend_pullback partial_tp_plus_mr 1.218655 0.553667 10.324692 1.327022 5.419408 5.419408 2101 572.016287 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.296650 3.174016 A_if_A_hits_first_else_B
27 M15 trend_pullback partial_tp_plus_mr 1.249852 0.595275 10.126766 1.545975 5.416597 5.416597 2259 527.653667 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.522877 3.281074 A_if_A_hits_first_else_B
28 M15 trend_pullback partial_tp_plus_mr 1.249852 0.595275 10.126766 1.545975 5.416597 5.416597 2259 527.653667 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.522877 3.281074 A_if_A_hits_first_else_B
29 M15 trend_pullback partial_tp_plus_mr 1.249852 0.595275 10.126766 1.545975 5.416597 5.416597 2259 527.653667 {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 2.522877 3.281074 A_if_A_hits_first_else_B
In [10]:
# Export top robust candidates for downstream wiring into GoldRegimeX_Explorer

leg_c_lot_rule = "A_if_A_hits_first_else_B"

handoff_cols = [
    "timeframe",
    "strategy_name",
    "exit_model",
    "parameter_set",
    "profit_factor",
    "sharpe",
    "sortino",
    "calmar",
    "max_drawdown",
    "expectancy",
    "win_rate",
    "trade_count",
    "profit_per_trade",
    "parameter_stability_score",
    "robust_score",
    "leg_c_lot_rule",
]

top_n = 30
handoff = (
    valid_results.sort_values(
        ["profit_per_trade", "robust_score", "profit_factor", "sharpe"],
        ascending=False,
    )
    .head(top_n)
    .copy()
)
handoff["leg_c_lot_rule"] = leg_c_lot_rule

handoff = handoff[handoff_cols].copy()

handoff.to_csv("reports/strategy_winners_for_explorer.csv", index=False)
print("Saved: reports/strategy_winners_for_explorer.csv")
display(handoff)
Saved: reports/strategy_winners_for_explorer.csv
timeframe strategy_name exit_model parameter_set profit_factor sharpe sortino calmar max_drawdown expectancy win_rate trade_count profit_per_trade parameter_stability_score robust_score leg_c_lot_rule
17 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.333323 4.278662 7.955413 58.609094 36.239360 13.857913 0.494563 2299 13.857913 -0.252988 5.003089 A_if_A_hits_first_else_B
18 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.332468 4.436246 8.300657 57.832734 39.161446 13.815454 0.496137 2459 13.815454 -0.252988 4.988058 A_if_A_hits_first_else_B
25 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.286177 3.762396 7.611348 48.019762 40.196617 13.391989 0.442183 2162 13.391989 -1.115868 4.442291 A_if_A_hits_first_else_B
26 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.286701 3.914677 7.942149 41.070814 50.269627 13.371856 0.443869 2316 13.371856 -1.115868 4.435349 A_if_A_hits_first_else_B
10 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.384734 4.935053 8.273799 73.654455 33.149792 13.155333 0.548491 2784 13.155333 0.780890 5.232714 A_if_A_hits_first_else_B
14 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.322699 4.697475 8.552221 68.284990 36.943924 13.130025 0.490285 2882 13.130025 0.544866 5.105238 A_if_A_hits_first_else_B
11 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.381952 4.723313 7.913530 75.399779 30.147308 13.088870 0.544722 2605 13.088870 0.780890 5.208895 A_if_A_hits_first_else_B
15 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.318266 4.628392 8.437445 67.233667 36.949325 13.011026 0.489176 2864 13.011026 0.544866 5.062702 A_if_A_hits_first_else_B
6 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.381456 5.331204 8.724625 85.963167 32.246865 12.700256 0.542150 3274 12.700256 1.617015 5.449038 A_if_A_hits_first_else_B
7 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.375666 5.247131 8.597868 84.759702 32.172308 12.578022 0.541513 3252 12.578022 1.617015 5.405098 A_if_A_hits_first_else_B
0 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.370537 4.188649 6.641625 53.813999 31.410040 12.520740 0.565432 2025 12.520740 3.229089 6.109456 A_if_A_hits_first_else_B
5 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.301341 3.568372 6.372474 36.320617 40.918838 12.412562 0.499443 1796 12.412562 2.288479 5.634481 A_if_A_hits_first_else_B
31 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.265143 3.959253 7.798584 56.230334 39.019899 12.243872 0.439732 2688 12.243872 -0.391266 4.362314 A_if_A_hits_first_else_B
33 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.260456 3.886657 7.661088 49.898391 43.140974 12.066509 0.438714 2676 12.066509 -0.391266 4.299300 A_if_A_hits_first_else_B
1 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.357223 4.392425 6.905418 81.682167 22.886268 11.896903 0.557064 2357 11.896903 3.382810 5.957625 A_if_A_hits_first_else_B
3475 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.310578 4.263791 7.151919 46.223372 41.931460 11.813573 0.512393 2461 11.813573 -0.142590 4.332700 A_if_A_hits_first_else_B
2 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.351545 4.318225 6.799578 80.616706 22.784849 11.789727 0.556697 2337 11.789727 3.382810 5.918978 A_if_A_hits_first_else_B
3464 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.373633 4.945160 7.509914 60.425570 36.116414 11.749937 0.557789 2786 11.749937 0.993453 4.834258 A_if_A_hits_first_else_B
3 M15 trend_pullback fixed_tp {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.332959 3.324974 4.995290 43.133688 24.472349 11.668194 0.563744 1357 11.668194 3.229089 5.803550 A_if_A_hits_first_else_B
35 M15 trend_pullback fixed_tp {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.266809 3.130916 5.612771 29.495884 45.260466 11.588520 0.487269 1728 11.588520 -0.252988 4.195499 A_if_A_hits_first_else_B
56906 M5 trend_pullback fixed_tp_plus_mr {"adx_threshold": 20, "atr_stop": 2.5, "atr_ta... 1.661116 13.694720 13.265897 168.202618 30.904727 11.439824 0.735329 6816 11.439824 5.406620 6.769141 A_if_A_hits_first_else_B
21 M15 trend_pullback fixed_tp {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.319938 3.609867 5.793276 35.944975 41.556502 11.437576 0.543645 1959 11.437576 0.780890 4.618540 A_if_A_hits_first_else_B
56907 M5 trend_pullback fixed_tp_plus_mr {"adx_threshold": 20, "atr_stop": 2.5, "atr_ta... 1.661413 13.589031 13.094294 157.205082 32.542292 11.422626 0.735338 6718 11.422626 5.406620 6.763181 A_if_A_hits_first_else_B
4 M15 trend_pullback fixed_tp {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.330536 3.633242 5.721421 48.838477 28.965524 11.420604 0.558127 1858 11.420604 3.229089 5.716408 A_if_A_hits_first_else_B
3456 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.345952 3.477543 4.954720 40.595645 25.366391 11.382811 0.568902 1357 11.382811 3.248301 5.714910 A_if_A_hits_first_else_B
3460 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.381059 5.476502 8.258916 67.028346 37.150244 11.373871 0.557247 3284 11.373871 1.887004 5.106218 A_if_A_hits_first_else_B
45 M15 trend_pullback fixed_tp {"adx_threshold": 25, "atr_stop": 2.0, "atr_ta... 1.237323 2.845330 5.509032 20.645726 60.701913 11.365493 0.440145 1654 11.365493 -1.115868 3.723247 A_if_A_hits_first_else_B
3467 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.360013 4.614446 6.948061 60.114298 32.777714 11.337222 0.554661 2607 11.337222 0.993453 4.687084 A_if_A_hits_first_else_B
3478 M15 trend_pullback fixed_tp_plus_mr {"adx_threshold": 22, "atr_stop": 2.0, "atr_ta... 1.297340 3.949863 6.530574 43.167814 40.236637 11.322866 0.511517 2301 11.322866 -0.142590 4.158306 A_if_A_hits_first_else_B
8 M15 trend_pullback fixed_tp {"adx_threshold": 18, "atr_stop": 2.0, "atr_ta... 1.276423 3.576917 6.310785 56.729663 27.711884 11.315397 0.491843 2084 11.315397 2.428817 5.308641 A_if_A_hits_first_else_B
In [11]:
# Regime Performance Attribution Analytics Report

def generate_regime_report(trades_df: pd.DataFrame):
    if trades_df.empty:
        print("Regime Report Error: Zero transaction logs generated.")
        return

    regimes = ["TREND", "SHOCK", "MR"]
    print("=" * 50)
    print("      REGIME RESEARCH ATTRIBUTION SUMMARY      ")
    print("=" * 50)

    total_pnl = trades_df["pnl_cents"].sum()

    for regime in regimes:
        r_trades = trades_df[trades_df["entry_regime"] == regime]

        if r_trades.empty:
            print(f"\nREGIME: {regime}\n" + "-" * 20)
            print("Buys: 0 | Sells: 0\nNet Profit: 0.00 Cents\nProfit Factor: N/A\nPnL Contribution: 0.0%")
            continue

        buys = len(r_trades[r_trades["side"] == "BUY"])
        sells = len(r_trades[r_trades["side"] == "SELL"])

        r_pnl = r_trades["pnl_cents"].to_numpy()
        gross_profit = np.sum(r_pnl[r_pnl > 0])
        gross_loss = -np.sum(r_pnl[r_pnl < 0])

        pf = gross_profit / gross_loss if gross_loss > 0 else (10.0 if gross_profit > 0 else 0.0)
        net_profit = np.sum(r_pnl)
        contribution = (net_profit / total_pnl * 100.0) if total_pnl != 0 else 0.0

        print(f"\nREGIME: {regime}\n" + "-" * 20)
        print(f"Buys: {buys} | Sells: {sells}")
        print(f"Net Profit: {net_profit:.2f} Cents")
        print(f"Profit Factor: {pf:.2f}")
        print(f"PnL Contribution: {contribution:.1f}%")

    print("=" * 50)


# Run a regime report on the top valid run for EACH Timeframe and EACH Strategy
if valid_results.empty:
    print("No valid_results available (trade_count >= 200). Run the backtest cell first.")
else:
    for tf in TIMEFRAMES:
        for strat_name in STRATEGIES.keys():
            # Filter for specific timeframe and strategy
            strat_results = valid_results[
                (valid_results["strategy_name"] == strat_name)
                & (valid_results["timeframe"] == tf)
            ]

            if strat_results.empty:
                print(f"\nNo valid runs found for {tf} | {strat_name}.")
                continue

            best_strat_row = strat_results.sort_values(
                ["robust_score", "profit_per_trade", "profit_factor", "sharpe"],
                ascending=False,
            ).iloc[0]

            exit_model = best_strat_row["exit_model"]

            combined_params = json.loads(best_strat_row["parameter_set"])
            entry_params = {
                k: combined_params[k]
                for k in STRATEGIES[strat_name].param_cols
                if k in combined_params
            }

            exit_keys = ["leg_a_atr_target", "time_stop_minutes", "trail_mult"]
            exit_params = {k: combined_params[k] for k in exit_keys if k in combined_params}

            df = FEATURES_BY_TF[tf]
            signals = generate_routed_signals(df, entry_params, strat_name)
            
            # FIX: Passed timeframe down correctly here
            trades_df, _ = run_backtest(
                timeframe=tf,
                df=df,
                signals=signals,
                entry_params=entry_params,
                exit_model=exit_model,
                exit_params=exit_params,
            )

            print(f"\n{'=' * 50}")
            print(f"  BEST RUN: {tf} | {strat_name.upper()} | {exit_model}")
            print(f"{'=' * 50}")
            generate_regime_report(trades_df)
==================================================
  BEST RUN: M15 | TREND_PULLBACK | fixed_tp
==================================================
==================================================
      REGIME RESEARCH ATTRIBUTION SUMMARY      
==================================================

REGIME: TREND
--------------------
Buys: 1018 | Sells: 1007
Net Profit: 25354.50 Cents
Profit Factor: 1.37
PnL Contribution: 100.0%

REGIME: SHOCK
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%

REGIME: MR
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%
==================================================

==================================================
  BEST RUN: M15 | VOLATILITY_EXPANSION | fixed_tp
==================================================
==================================================
      REGIME RESEARCH ATTRIBUTION SUMMARY      
==================================================

REGIME: TREND
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%

REGIME: SHOCK
--------------------
Buys: 593 | Sells: 556
Net Profit: -51625.07 Cents
Profit Factor: 0.09
PnL Contribution: 100.0%

REGIME: MR
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%
==================================================

==================================================
  BEST RUN: M5 | TREND_PULLBACK | fixed_tp_plus_mr
==================================================
==================================================
      REGIME RESEARCH ATTRIBUTION SUMMARY      
==================================================

REGIME: TREND
--------------------
Buys: 3246 | Sells: 3222
Net Profit: 73101.97 Cents
Profit Factor: 1.62
PnL Contribution: 100.0%

REGIME: SHOCK
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%

REGIME: MR
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%
==================================================

==================================================
  BEST RUN: M5 | VOLATILITY_EXPANSION | fixed_tp
==================================================
==================================================
      REGIME RESEARCH ATTRIBUTION SUMMARY      
==================================================

REGIME: TREND
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%

REGIME: SHOCK
--------------------
Buys: 2271 | Sells: 1921
Net Profit: -30829.00 Cents
Profit Factor: 0.79
PnL Contribution: 100.0%

REGIME: MR
--------------------
Buys: 0 | Sells: 0
Net Profit: 0.00 Cents
Profit Factor: N/A
PnL Contribution: 0.0%
==================================================
In [13]:
MAX_DD_PCT = 30.0  # adjust cap (percent)

cols = [
    "timeframe", "strategy_name", "exit_model",
    "trade_count", "net_profit", "profit_per_trade",
    "max_drawdown", "robust_score", "profit_factor", "sharpe"
]

filtered = all_results[
    all_results["max_drawdown"] <= MAX_DD_PCT
]

display(
    filtered[cols]
    .sort_values(["timeframe", "robust_score"], ascending=[True, False])
    .reset_index(drop=True)
)

print("Remaining rows:", len(filtered))
timeframe strategy_name exit_model trade_count net_profit profit_per_trade max_drawdown robust_score profit_factor sharpe
0 M15 trend_pullback fixed_tp 2357 28040.999725 11.896903 22.886268 5.957625 1.357223 4.392425
1 M15 trend_pullback fixed_tp 2337 27552.591666 11.789727 22.784849 5.918978 1.351545 4.318225
2 M15 trend_pullback fixed_tp 1357 15833.739736 11.668194 24.472349 5.803550 1.332959 3.324974
3 M15 trend_pullback fixed_tp 1858 21219.481344 11.420604 28.965524 5.716408 1.330536 3.633242
4 M15 trend_pullback fixed_tp_plus_mr 1357 15446.475164 11.382811 25.366391 5.714910 1.345952 3.477543
... ... ... ... ... ... ... ... ... ... ...
336 M5 trend_pullback fixed_tp 14312 84254.936143 5.887013 20.856681 2.974953 1.484201 14.668865
337 M5 trend_pullback fixed_tp_plus_mr 12554 49489.257626 3.942111 25.950788 2.927770 1.269322 7.440165
338 M5 trend_pullback fixed_tp 12559 48441.986442 3.857153 25.844390 2.885858 1.256401 7.151611
339 M5 trend_pullback fixed_tp_plus_mr 12757 47501.881124 3.723593 26.781701 2.847928 1.252517 7.094549
340 M5 trend_pullback fixed_tp 12760 46322.502844 3.630290 27.029216 2.803126 1.239750 6.796936

341 rows × 10 columns

Remaining rows: 341