#!/usr/bin/env python3
"""
robustness_testing.py
Statistical robustness testing for MQL5 CSV strategy exports.

Implements three non-parametric robustness tests:
  Module A: Permutation test on Sortino Ratio
  Module B: Bootstrap BCa confidence intervals
  Module C: Monte Carlo trade-sequence shuffling

Requires: Python 3.8+, numpy >= 1.23, pandas >= 1.5,
          scipy >= 1.10, matplotlib >= 3.7, seaborn >= 0.12.

Install:
    pip install numpy pandas scipy matplotlib seaborn

Usage:
    python robustness_testing.py
"""

import os
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats as scipy_stats
from typing import Optional

warnings.filterwarnings("ignore", category=FutureWarning)

# ── Output Sizing ─────────────────────────────────────────────────────────────
# All charts are capped at 980px wide. At 120 dpi, 980px = 8.1667 inches.
FIG_DPI      = 120
FIG_WIDTH_IN = 980 / FIG_DPI   # 8.1667 inches -> exactly 980px wide

# ── Light Theme Palette ───────────────────────────────────────────────────────
COL_TITLE    = "#1a1f24"   # near-black title text
COL_TEXT     = "#24292f"   # primary text / axis labels
COL_MUTED    = "#57606a"   # ticks, captions, secondary text
COL_GRID     = "#d0d7de"   # light grid lines
COL_EDGE     = "#d0d7de"   # axis spine colour
COL_BLUE     = "#1f6feb"   # primary data series
COL_RED      = "#cf222e"   # p-value tail / worst-case
COL_GREEN    = "#1a7f37"   # favourable / significant
COL_AMBER    = "#9a6700"   # caution band
COL_ORANGE   = "#bc4c00"   # observed marker
COL_REF      = "#8c959f"   # neutral reference lines

# ── Global Style (light) ──────────────────────────────────────────────────────
plt.rcParams.update({
    "figure.facecolor" : "#ffffff", "axes.facecolor"  : "#ffffff",
    "axes.edgecolor"   : COL_EDGE,  "axes.labelcolor" : COL_TEXT,
    "xtick.color"      : COL_MUTED, "ytick.color"     : COL_MUTED,
    "text.color"       : COL_TEXT,  "grid.color"      : COL_GRID,
    "grid.linestyle"   : "--",      "grid.linewidth"  : 0.6,
    "font.family"      : "monospace","font.size"      : 9,
    "savefig.facecolor": "#ffffff",
})


# ─────────────────────────────────────────────────────────────────────────────
# Shared Utilities
# ─────────────────────────────────────────────────────────────────────────────

def _read_csv_flexible(path: str) -> pd.DataFrame:
    """
    Reads a CSV produced by the MQL5 exporter. MetaTrader writes ANSI
    encoded files. On Windows the 'ansi' alias resolves natively; on
    other platforms it is not a known codec, so this helper tries a
    sequence of encodings and returns the first that succeeds.
    """
    for enc in ("cp1252", "utf-8", "latin-1"):
        try:
            return pd.read_csv(path, encoding=enc)
        except (UnicodeDecodeError, LookupError):
            continue
    # Final fallback: replace undecodable bytes rather than fail outright
    return pd.read_csv(path, encoding="latin-1")


def compute_sortino(profits: np.ndarray) -> float:
    """
    Computes the trade-level Sortino Ratio from a 1D array of
    trade profit values (USD). Returns 0.0 if the downside
    deviation is zero (all trades profitable).
    """
    if len(profits) == 0:
        return 0.0
    mean_profit    = profits.mean()
    negative_profs = profits[profits < 0]
    if len(negative_profs) == 0:
        return mean_profit / 1e-9 if mean_profit > 0 else 0.0
    downside_dev = np.sqrt(np.mean(negative_profs ** 2))
    if downside_dev <= 0:
        return 0.0
    return mean_profit / downside_dev


def equity_curve_from_trades(profits        : np.ndarray,
                              initial_equity : float = 10_000.0) -> np.ndarray:
    """
    Constructs a cumulative equity curve from a 1D array of per-trade
    profit values, starting from initial_equity.
    """
    return initial_equity + np.concatenate([[0.0], np.cumsum(profits)])


def max_drawdown_from_equity(equity: np.ndarray) -> float:
    """Maximum peak-to-trough drawdown as an absolute USD value."""
    running_max = np.maximum.accumulate(equity)
    return float((running_max - equity).max())


def max_drawdown_pct_from_equity(equity: np.ndarray) -> float:
    """Maximum drawdown as a percentage of the running peak equity."""
    running_max = np.maximum.accumulate(equity)
    dd_pct      = np.where(running_max > 0,
                           (running_max - equity) / running_max * 100.0,
                           0.0)
    return float(dd_pct.max())


# ─────────────────────────────────────────────────────────────────────────────
# Module A: Permutation Test
# ─────────────────────────────────────────────────────────────────────────────

def permutation_test(df             : pd.DataFrame,
                     profit_col     : str   = "Trade_Profit_USD",
                     n_permutations : int   = 10_000,
                     alpha          : float = 0.05,
                     random_seed    : int   = 42) -> dict:
    """
    Sign-randomization permutation test for the Sortino Ratio.

    The Sortino Ratio is order-independent: shuffling the sequence of
    trade returns does not change it, because the mean and downside
    deviation both use the full set of returns regardless of order.
    A meaningful null must therefore destroy the *directional edge*
    rather than the order.

    This test builds the null distribution by randomly flipping the
    sign of each trade's return on every iteration. Under the null
    hypothesis of no directional edge, a positive and a negative
    outcome of equal magnitude are equally likely, so each return is
    independently negated with probability 0.5. The proportion of
    sign-flipped Sortino values that meet or exceed the observed
    Sortino is the one-tailed p-value.
    """
    rng     = np.random.default_rng(random_seed)
    profits = df[profit_col].dropna().values.astype(float)
    if len(profits) < 10:
        raise ValueError(f"Need >= 10 trades. Found: {len(profits)}.")

    observed  = compute_sortino(profits)

    null_dist = np.empty(n_permutations)
    n         = len(profits)
    for i in range(n_permutations):
        signs        = rng.choice([-1.0, 1.0], size=n)
        null_dist[i] = compute_sortino(profits * signs)
    p_value     = float(np.mean(null_dist >= observed))
    significant = p_value < alpha

    if p_value < 0.01:
        interp = (f"Highly significant (p={p_value:.4f}). Sortino={observed:.4f} "
                  f"is extremely unlikely under the null.")
    elif p_value < alpha:
        interp = (f"Significant (p={p_value:.4f}). Sortino={observed:.4f} "
                  f"exceeds the {alpha:.0%} threshold.")
    elif p_value < 0.10:
        interp = (f"Marginally significant (p={p_value:.4f}). "
                  f"Interpret with caution.")
    else:
        interp = (f"Not significant (p={p_value:.4f}). "
                  f"Sortino={observed:.4f} is consistent with random chance.")

    return {"observed_sortino" : observed, "null_distribution" : null_dist,
            "p_value" : p_value, "significant" : significant,
            "interpretation" : interp, "n_trades" : len(profits),
            "n_permutations" : n_permutations}


def plot_permutation_null(result    : dict,
                          indicator : str = "",
                          save_path : str = None):
    """
    Plots the permutation null distribution as a histogram with the
    observed Sortino Ratio overlaid as a vertical reference line.
    """
    null_dist = result["null_distribution"]
    observed  = result["observed_sortino"]
    p_val     = result["p_value"]
    title_tag = f"  |  {indicator}" if indicator else ""

    fig, ax = plt.subplots(figsize=(FIG_WIDTH_IN, 4.6))
    fig.suptitle(f"Permutation Test: Sortino Null Distribution{title_tag}",
                 fontsize=11, fontweight="bold", color=COL_TITLE)

    #--- Adaptive bin count: a degenerate null (near-constant values)
    #--- cannot support many bins, so cap bins by the unique value count.
    n_unique = len(np.unique(null_dist))
    n_bins   = int(max(10, min(80, n_unique)))

    ax.hist(null_dist, bins=n_bins, color=COL_BLUE,
            alpha=0.55, edgecolor="none", density=True,
            label="Null Distribution")

    tail_vals = null_dist[null_dist >= observed]
    if len(tail_vals) > 1 and len(np.unique(tail_vals)) > 1:
        ax.hist(tail_vals, bins=max(10, min(40, len(np.unique(tail_vals)))),
                color=COL_RED, alpha=0.55,
                edgecolor="none", density=True,
                label=f"p-value region ({p_val:.4f})")

    ax.axvline(observed, color=COL_ORANGE, linewidth=2.0,
               linestyle="--", label=f"Observed: {observed:.4f}")

    for pct in [95, 99]:
        pv = np.percentile(null_dist, pct)
        ax.axvline(pv, color=COL_REF, linewidth=0.8, linestyle=":", alpha=0.8)
        ax.text(pv + 0.002, ax.get_ylim()[1] * 0.92,
                f"p{pct}", fontsize=6.5, color=COL_MUTED, ha="left")

    ax.set_xlabel("Sortino Ratio (Permuted)", fontsize=9, labelpad=7)
    ax.set_ylabel("Probability Density",      fontsize=9, labelpad=7)
    ax.legend(fontsize=8, loc="upper left",
              framealpha=0.85, edgecolor=COL_EDGE)
    ax.grid(True, alpha=0.4)
    ax.spines[["top", "right"]].set_visible(False)

    sig_label = "SIGNIFICANT" if result["significant"] else "NOT SIGNIFICANT"
    sig_color = COL_GREEN      if result["significant"] else COL_RED
    ax.text(0.98, 0.06, sig_label, transform=ax.transAxes,
            fontsize=9, fontweight="bold", color=sig_color,
            ha="right", va="bottom")

    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=FIG_DPI, facecolor="#ffffff")
    plt.close(fig)


# ─────────────────────────────────────────────────────────────────────────────
# Module B: Bootstrap CI
# ─────────────────────────────────────────────────────────────────────────────

def bootstrap_ci(df           : pd.DataFrame,
                 profit_col   : str   = "Trade_Profit_USD",
                 metrics      : list  = None,
                 n_bootstrap  : int   = 10_000,
                 confidence   : float = 0.95,
                 random_seed  : int   = 42) -> pd.DataFrame:
    """
    Computes bootstrap confidence intervals for key performance
    metrics using both percentile and BCa methods.
    """
    if metrics is None:
        metrics = ["sortino", "mean_profit", "win_rate",
                   "max_drawdown", "profit_factor"]
    rng     = np.random.default_rng(random_seed)
    profits = df[profit_col].dropna().values.astype(float)
    n       = len(profits)
    if n < 10:
        raise ValueError(f"Bootstrap needs >= 10 trades. Found: {n}.")
    alpha_tail = (1.0 - confidence) / 2.0

    def compute_metric(p: np.ndarray, name: str) -> float:
        if name == "sortino":
            return compute_sortino(p)
        elif name == "mean_profit":
            return float(p.mean())
        elif name == "win_rate":
            return float(np.mean(p > 0) * 100.0)
        elif name == "max_drawdown":
            eq = equity_curve_from_trades(p)
            return max_drawdown_pct_from_equity(eq)
        elif name == "profit_factor":
            gp = p[p > 0].sum()
            gl = abs(p[p < 0].sum())
            return float(gp / gl) if gl > 0 else np.inf
        return 0.0

    rows = []
    for metric in metrics:
        observed  = compute_metric(profits, metric)
        boot_vals = np.array([
            compute_metric(rng.choice(profits, size=n, replace=True), metric)
            for _ in range(n_bootstrap)
        ])
        ci_lo_pct = float(np.percentile(boot_vals, alpha_tail * 100))
        ci_hi_pct = float(np.percentile(boot_vals, (1 - alpha_tail) * 100))

        prop      = np.clip(np.mean(boot_vals < observed), 1e-6, 1 - 1e-6)
        z0        = scipy_stats.norm.ppf(prop)
        jack_vals = np.array([compute_metric(np.delete(profits, j), metric)
                              for j in range(n)])
        jm        = jack_vals.mean()
        num       = np.sum((jm - jack_vals) ** 3)
        den       = 6.0 * (np.sum((jm - jack_vals) ** 2) ** 1.5)
        accel     = num / den if den != 0 else 0.0

        def bca_q(za):
            z_adj = z0 + (z0 + za) / (1 - accel * (z0 + za))
            return float(np.percentile(boot_vals,
                                       scipy_stats.norm.cdf(z_adj) * 100))

        rows.append({
            "Metric"       : metric,
            "Observed"     : round(observed, 4),
            "CI_Lower_Pct" : round(ci_lo_pct, 4),
            "CI_Upper_Pct" : round(ci_hi_pct, 4),
            "CI_Lower_BCa" : round(bca_q(scipy_stats.norm.ppf(alpha_tail)), 4),
            "CI_Upper_BCa" : round(bca_q(scipy_stats.norm.ppf(1-alpha_tail)), 4),
            "Std_Error"    : round(float(boot_vals.std()), 4),
        })
    return pd.DataFrame(rows)


def plot_bootstrap_ci_comparison(ci_results_map : dict,
                                 metric         : str  = "sortino",
                                 save_path      : str  = None):
    """
    Plots BCa confidence intervals for one metric across multiple
    indicator variants as a horizontal error bar chart.
    """
    rows = []
    for indicator, ci_df in ci_results_map.items():
        mask = ci_df["Metric"] == metric
        if not mask.any():
            continue
        row = ci_df.loc[mask].iloc[0]
        rows.append({"Indicator" : indicator, "Observed" : row["Observed"],
                     "Lower" : row["CI_Lower_BCa"], "Upper" : row["CI_Upper_BCa"],
                     "Error_Lo" : row["Observed"] - row["CI_Lower_BCa"],
                     "Error_Hi" : row["CI_Upper_BCa"] - row["Observed"]})
    if not rows:
        return
    plot_df    = pd.DataFrame(rows).sort_values("Observed", ascending=True)
    indicators = plot_df["Indicator"].values
    observed   = plot_df["Observed"].values
    err_lo     = plot_df["Error_Lo"].values
    err_hi     = plot_df["Error_Hi"].values
    palette    = sns.color_palette("deep", len(indicators))
    y_pos      = np.arange(len(indicators))

    fig, ax = plt.subplots(
        figsize=(FIG_WIDTH_IN, max(3.4, len(indicators) * 0.7 + 1.6))
    )
    fig.suptitle(
        f"Bootstrap BCa Confidence Intervals  |  "
        f"{metric.replace('_',' ').title()} by Indicator",
        fontsize=11, fontweight="bold", color=COL_TITLE
    )
    for i, (ind, obs, elo, ehi, color) in enumerate(
        zip(indicators, observed, err_lo, err_hi, palette)
    ):
        ax.errorbar(obs, i, xerr=[[elo], [ehi]],
                    fmt="o", color=color, markersize=7,
                    linewidth=1.8, capsize=5, capthick=1.5,
                    ecolor=color, alpha=0.95)
        ax.text(obs + max(err_hi) * 0.04, i,
                f"{obs:.4f}", va="center", ha="left",
                fontsize=7, color=COL_TEXT)
    ax.axvline(0, color=COL_REF, linewidth=0.9, linestyle=":")
    ax.set_yticks(y_pos)
    ax.set_yticklabels(indicators, fontsize=8)
    ax.set_xlabel(f"{metric.replace('_',' ').title()} (BCa 95% CI)",
                  fontsize=9, labelpad=7)
    ax.grid(axis="x", alpha=0.4)
    ax.spines[["top", "right"]].set_visible(False)
    ax.set_title(
        "Error bars show BCa-corrected 95% bootstrap confidence intervals",
        fontsize=7.5, color=COL_MUTED, style="italic", pad=6
    )
    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=FIG_DPI, facecolor="#ffffff")
    plt.close(fig)


# ─────────────────────────────────────────────────────────────────────────────
# Module C: Monte Carlo Trade-Sequence Shuffling
# ─────────────────────────────────────────────────────────────────────────────

def monte_carlo_equity_simulation(
        df              : pd.DataFrame,
        profit_col      : str   = "Trade_Profit_USD",
        n_simulations   : int   = 10_000,
        initial_equity  : float = 10_000.0,
        random_seed     : int   = 42) -> dict:
    """
    Monte Carlo trade-sequence shuffling simulation.

    Randomly permutes the observed trade P&L sequence n_simulations
    times, reconstructs the equity curve for each permutation, and
    records the maximum drawdown.
    """
    rng     = np.random.default_rng(random_seed)
    profits = df[profit_col].dropna().values.astype(float)
    n       = len(profits)
    if n < 5:
        raise ValueError(f"Monte Carlo needs >= 5 trades. Found: {n}.")

    obs_eq      = equity_curve_from_trades(profits, initial_equity)
    obs_dd_usd  = max_drawdown_from_equity(obs_eq)
    obs_dd_pct  = max_drawdown_pct_from_equity(obs_eq)

    store_fan   = n_simulations <= 5_000
    equity_fan  = np.empty((n_simulations, n + 1)) if store_fan else None
    sim_dd_usd  = np.empty(n_simulations)
    sim_dd_pct  = np.empty(n_simulations)

    for i in range(n_simulations):
        sh              = rng.permutation(profits)
        eq              = equity_curve_from_trades(sh, initial_equity)
        sim_dd_usd[i]   = max_drawdown_from_equity(eq)
        sim_dd_pct[i]   = max_drawdown_pct_from_equity(eq)
        if store_fan:
            equity_fan[i] = eq

    pct_levels = [5, 25, 50, 75, 95]
    return {
        "observed_max_dd_usd"   : obs_dd_usd,
        "observed_max_dd_pct"   : obs_dd_pct,
        "simulated_dd_usd"      : sim_dd_usd,
        "simulated_dd_pct"      : sim_dd_pct,
        "observed_equity_curve" : obs_eq,
        "simulated_equity_fan"  : equity_fan,
        "percentiles_usd"       : {p: float(np.percentile(sim_dd_usd, p))
                                   for p in pct_levels},
        "percentiles_pct"       : {p: float(np.percentile(sim_dd_pct, p))
                                   for p in pct_levels},
        "observed_dd_pct_rank"  : float(np.mean(sim_dd_pct <= obs_dd_pct) * 100),
        "n_trades"              : n,
        "n_simulations"         : n_simulations,
        "initial_equity"        : initial_equity,
    }


def plot_monte_carlo_results(mc_result : dict,
                             indicator : str  = "",
                             save_path : str  = None):
    """
    Two-panel Monte Carlo visualization:
      Left panel:  Equity curve fan (simulated paths + observed path).
      Right panel: Simulated drawdown distribution with observed marker.
    """
    obs_equity = mc_result["observed_equity_curve"]
    equity_fan = mc_result["simulated_equity_fan"]
    sim_dd_pct = mc_result["simulated_dd_pct"]
    obs_dd_pct = mc_result["observed_max_dd_pct"]
    pct_pct    = mc_result["percentiles_pct"]
    obs_rank   = mc_result["observed_dd_pct_rank"]
    n_trades   = mc_result["n_trades"]
    title_tag  = f"  |  {indicator}" if indicator else ""

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(FIG_WIDTH_IN, 4.8))
    fig.suptitle(f"Monte Carlo Trade-Sequence Analysis{title_tag}",
                 fontsize=11, fontweight="bold", color=COL_TITLE)

    x = np.arange(n_trades + 1)
    if equity_fan is not None:
        p5  = np.percentile(equity_fan, 5,  axis=0)
        p25 = np.percentile(equity_fan, 25, axis=0)
        p75 = np.percentile(equity_fan, 75, axis=0)
        p95 = np.percentile(equity_fan, 95, axis=0)
        p50 = np.percentile(equity_fan, 50, axis=0)
        ax1.fill_between(x, p5,  p95,  color=COL_BLUE, alpha=0.12,
                         label="P5-P95 Band")
        ax1.fill_between(x, p25, p75,  color=COL_BLUE, alpha=0.25,
                         label="P25-P75 Band")
        ax1.plot(x, p50, color=COL_BLUE, linewidth=1.2,
                 linestyle="--", alpha=0.80, label="Median Simulated")
    ax1.plot(x, obs_equity, color=COL_ORANGE,
             linewidth=2.0, zorder=5, label="Observed Path")
    ax1.axhline(mc_result["initial_equity"],
                color=COL_REF, linewidth=0.8, linestyle=":")
    ax1.set_xlabel("Trade Number", fontsize=9, labelpad=7)
    ax1.set_ylabel("Equity (USD)", fontsize=9, labelpad=7)
    ax1.set_title("Equity Fan",    fontsize=9, color=COL_MUTED, pad=6)
    ax1.legend(fontsize=7, loc="upper left",
               framealpha=0.85, edgecolor=COL_EDGE)
    ax1.grid(True, alpha=0.4)
    ax1.spines[["top", "right"]].set_visible(False)

    sns.kdeplot(sim_dd_pct, ax=ax2, color=COL_BLUE,
                linewidth=2.0, fill=True, alpha=0.20)
    dd_bins = int(max(10, min(50, len(np.unique(sim_dd_pct)))))
    ax2.hist(sim_dd_pct, bins=dd_bins, density=True,
             color=COL_BLUE, alpha=0.12, edgecolor="none", zorder=0)
    pct_colors = {5:COL_GREEN, 25:COL_AMBER, 50:COL_MUTED,
                  75:COL_AMBER, 95:COL_RED}
    keys_list  = list(pct_pct.keys())
    for pct, val in pct_pct.items():
        ax2.axvline(val, color=pct_colors.get(pct, COL_MUTED),
                    linewidth=0.9, linestyle=":", alpha=0.85)
        ax2.text(val + 0.05,
                 ax2.get_ylim()[1] * (0.88 - 0.12 * keys_list.index(pct)),
                 f"P{pct}:{val:.1f}%",
                 fontsize=6.0, color=pct_colors.get(pct, COL_MUTED),
                 ha="left")
    ax2.axvline(obs_dd_pct, color=COL_ORANGE, linewidth=2.0,
                linestyle="--", label=f"Observed: {obs_dd_pct:.1f}%")
    ax2.set_xlabel("Max Drawdown (%)",     fontsize=9, labelpad=7)
    ax2.set_ylabel("Density",              fontsize=9, labelpad=7)
    ax2.set_title("Drawdown Distribution", fontsize=9, color=COL_MUTED, pad=6)
    ax2.legend(fontsize=7, loc="upper right",
               framealpha=0.85, edgecolor=COL_EDGE)
    rank_color = COL_GREEN if obs_rank <= 30 else (
                 COL_AMBER if obs_rank <= 60 else COL_RED)
    ax2.text(0.97, 0.08,
             f"Observed DD @ P{obs_rank:.0f}\nof simulated distribution",
             transform=ax2.transAxes, fontsize=7.5,
             color=rank_color, ha="right", va="bottom",
             bbox=dict(boxstyle="round,pad=0.3",
                       facecolor="#f6f8fa",
                       edgecolor=COL_EDGE, alpha=0.95))
    ax2.grid(True, alpha=0.4)
    ax2.spines[["top", "right"]].set_visible(False)
    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=FIG_DPI, facecolor="#ffffff")
    plt.close(fig)


# ─────────────────────────────────────────────────────────────────────────────
# Unified Robustness Battery
# ─────────────────────────────────────────────────────────────────────────────

def run_robustness_battery(
        trade_csv_path  : str,
        output_dir      : str   = "robustness_outputs",
        n_permutations  : int   = 10_000,
        n_bootstrap     : int   = 10_000,
        n_monte_carlo   : int   = 5_000,
        alpha           : float = 0.05,
        random_seed     : int   = 42) -> pd.DataFrame:
    """
    Runs the complete robustness testing battery (permutation test,
    bootstrap CI, Monte Carlo simulation) for each unique
    Indicator_Name in the trade-level CSV export.
    """
    os.makedirs(output_dir, exist_ok=True)
    if not os.path.isfile(trade_csv_path):
        raise FileNotFoundError(f"Trade CSV not found: {trade_csv_path}")
    df = _read_csv_flexible(trade_csv_path)
    if "Trade_Profit_USD" not in df.columns:
        raise ValueError("Column 'Trade_Profit_USD' not found.")

    indicators     = (df["Indicator_Name"].unique()
                      if "Indicator_Name" in df.columns
                      else ["All_Trades"])
    summary_rows   = []
    ci_results_map = {}

    for indicator in indicators:
        print(f"\n[Robustness] Testing: {indicator}")
        subset = (df[df["Indicator_Name"] == indicator].copy()
                  if "Indicator_Name" in df.columns else df.copy())
        if len(subset) < 10:
            print(f"  [Skip] Insufficient trades ({len(subset)}).")
            continue

        print(f"  Running permutation test ({n_permutations:,} iterations)...")
        perm_result = permutation_test(
            subset, n_permutations=n_permutations,
            alpha=alpha, random_seed=random_seed)
        plot_permutation_null(
            perm_result, indicator=indicator,
            save_path=os.path.join(output_dir,
                                   f"Permutation_Null_{indicator}.png"))

        print(f"  Running bootstrap CI ({n_bootstrap:,} resamples)...")
        ci_df = bootstrap_ci(
            subset, n_bootstrap=n_bootstrap, random_seed=random_seed)
        ci_results_map[indicator] = ci_df
        ci_df.to_csv(os.path.join(output_dir,
                                  f"Bootstrap_CI_{indicator}.csv"),
                     index=False)

        print(f"  Running Monte Carlo simulation ({n_monte_carlo:,} paths)...")
        mc_result = monte_carlo_equity_simulation(
            subset, n_simulations=n_monte_carlo, random_seed=random_seed)
        plot_monte_carlo_results(
            mc_result, indicator=indicator,
            save_path=os.path.join(output_dir,
                                   f"MonteCarlo_{indicator}.png"))

        sortino_ci = (ci_df[ci_df["Metric"] == "sortino"].iloc[0]
                      if not ci_df[ci_df["Metric"] == "sortino"].empty
                      else None)
        summary_rows.append({
            "Indicator"             : indicator,
            "N_Trades"              : perm_result["n_trades"],
            "Observed_Sortino"      : perm_result["observed_sortino"],
            "Permutation_P_Value"   : perm_result["p_value"],
            "Permutation_Sig"       : perm_result["significant"],
            "Bootstrap_Sortino_Lo"  : (sortino_ci["CI_Lower_BCa"]
                                       if sortino_ci is not None else None),
            "Bootstrap_Sortino_Hi"  : (sortino_ci["CI_Upper_BCa"]
                                       if sortino_ci is not None else None),
            "MC_Obs_DD_Pct"         : mc_result["observed_max_dd_pct"],
            "MC_DD_P50"             : mc_result["percentiles_pct"][50],
            "MC_DD_P95"             : mc_result["percentiles_pct"][95],
            "MC_Obs_DD_Rank"        : mc_result["observed_dd_pct_rank"],
        })
        print(f"  {perm_result['interpretation']}")
        print(f"  MC DD: Obs={mc_result['observed_max_dd_pct']:.1f}%  "
              f"P50={mc_result['percentiles_pct'][50]:.1f}%  "
              f"P95={mc_result['percentiles_pct'][95]:.1f}%  "
              f"Rank=P{mc_result['observed_dd_pct_rank']:.0f}")

    if len(ci_results_map) > 1:
        plot_bootstrap_ci_comparison(
            ci_results_map, metric="sortino",
            save_path=os.path.join(output_dir,
                                   "Bootstrap_CI_Comparison_Sortino.png"))

    summary_df = pd.DataFrame(summary_rows)
    summary_df.to_csv(os.path.join(output_dir, "Robustness_Summary.csv"),
                      index=False)
    print(f"\n[Robustness] Summary: "
          f"{os.path.join(output_dir, 'Robustness_Summary.csv')}")
    return summary_df


# ── Entry Point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
    run_robustness_battery(
        trade_csv_path = "exports/trade_level_results.csv",
        output_dir     = "robustness_outputs",
        n_permutations = 10_000,
        n_bootstrap    = 10_000,
        n_monte_carlo  = 5_000,   # 5,000 keeps equity fan storage tractable
        alpha          = 0.05,
        random_seed    = 42
    )