mirror of
https://github.com/lucasmos/GoldRegime_X.git
synced 2026-11-02 06:26:15 +00:00
- 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.
218 KiB
218 KiB
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 routedIn [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