#!/usr/bin/env python3
"""
Tie together DataLoader, MomentumFilter, StatArbEngine, and StrategyOrchestrator.

Fetches:
  * Daily data for regime asset (default SPY)
  * Intraday data for traded asset (default QQQ or AAPL)

Backtest (vectorized, close-to-close on each bar):
  * ``standalone_ret`` = lagged ``micro_position`` * bar return
  * ``filtered_ret``   = lagged ``orchestrated_position`` * bar return

Pair mode (``--hedge-ticker``): causal OLS hedge, z-score on causal spread; bar
return is ``basket_ret`` (Y return minus lagged β times X return).

Plots cumulative compounded returns for both (requires matplotlib).

Run from repository root::

    .venv/bin/python RenTech/strategy_stack/main.py
    .venv/bin/python RenTech/strategy_stack/main.py --trade-ticker AAPL --regime-ticker SPY
    .venv/bin/python RenTech/strategy_stack/main.py --trade-ticker QQQ --hedge-ticker SPY
    .venv/bin/python RenTech/strategy_stack/main.py --mode hourly-swing-breakout --intraday-period 730d
    # default: macro ETFs (GLD, USO, TLT, DBC, UUP, …); use --hourly-swing-universe sp100 for equities
"""

from __future__ import annotations

import argparse
import calendar
import json
import os
import sys
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd

# Repository root (parent of RenTech/)
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)

from RenTech.strategy_stack.data_loader import DataLoader
from RenTech.strategy_stack.momentum_filter import MomentumFilter
from RenTech.strategy_stack.statarb_engine import PairStatArbEngine, StatArbEngine
from RenTech.strategy_stack.strategy_orchestrator import StrategyOrchestrator
from RenTech.strategy_stack.swing_engine import SwingEngine

# US-listed ETFs for --mode hourly-swing-breakout when --hourly-swing-universe macro-etfs (default).
# Gold/silver, oil, Treasuries, broad commodities, currency proxies (not spot FX), VIX futures proxies.
HOURLY_SWING_MACRO_ETF_TICKERS: tuple[str, ...] = (
    "GLD",
    "IAU",
    "SLV",
    "USO",
    "BNO",
    "TLT",
    "IEF",
    "SHY",
    "DBC",
    "GSG",
    "UUP",
    "FXE",
    "FXY",
    "VXX",
    "SVXY",
)


def vectorized_strategy_returns(df: pd.DataFrame, pos_col: str, ret_col: str = "ret") -> pd.Series:
    """pos_{t-1} * ret_t (no lookahead on same bar close)."""
    return df[pos_col].shift(1).fillna(0).astype(np.float64) * df[ret_col].astype(np.float64)


def equity_curve(simple_rets: pd.Series) -> pd.Series:
    """Compounded growth of 1.0."""
    r = simple_rets.fillna(0.0).astype(np.float64)
    return (1.0 + r).cumprod() - 1.0


def run_pipeline(
    *,
    regime_ticker: str = "SPY",
    trade_ticker: str = "QQQ",
    daily_period: str = "5y",
    intraday_period: str = "730d",
    intraday_interval: str = "1h",
    sma_window: int = 200,
    roc_period: int = 126,
    aqr_lookback: int = 252,
    aqr_skip: int = 21,
    z_window: int = 20,
    z_entry: float = 2.0,
    z_exit: float = 0.0,
    regime_shift_sessions: int = 0,
    hedge_ticker: str | None = None,
    hedge_window: int = 0,
    min_hedge_obs: int = 30,
) -> pd.DataFrame:
    loader = DataLoader()
    daily = loader.fetch_daily(regime_ticker, period=daily_period)
    if daily.empty:
        raise RuntimeError(f"No daily data for {regime_ticker}")

    # AQR 12-minus-1 momentum gate (skip recent month to reduce short-term mean reversion).
    mf = MomentumFilter(sma_window=sma_window, aqr_lookback=aqr_lookback, aqr_skip=aqr_skip)
    daily_regime = mf.transform(daily["close"])

    iv = "60m" if intraday_interval in ("1h", "60m") else intraday_interval
    intra_y = loader.fetch_intraday(trade_ticker, interval=iv, period=intraday_period)  # type: ignore[arg-type]
    if intra_y.empty:
        raise RuntimeError(f"No intraday data for {trade_ticker} — try shorter period or interval")

    intra_y = loader.align_to_trading_days(intra_y)

    if hedge_ticker:
        intra_x = loader.fetch_intraday(hedge_ticker, interval=iv, period=intraday_period)  # type: ignore[arg-type]
        if intra_x.empty:
            raise RuntimeError(f"No intraday data for hedge {hedge_ticker}")
        intra_x = loader.align_to_trading_days(intra_x)
        hw = None if hedge_window <= 0 else hedge_window
        peng = PairStatArbEngine(
            window=z_window,
            entry_z=z_entry,
            exit_z=z_exit,
            hedge_window=hw,
            min_hedge_obs=min_hedge_obs,
        )
        intra = peng.transform(intra_y, intra_x)
        ar = peng.adf_report
        hmode = "expanding" if hw is None else f"rolling {hw}"
        if "pvalue" in ar:
            print(
                f"📌 Pair causal OLS ({hmode}, min_train={min_hedge_obs}) | "
                f"ADF p-value={ar['pvalue']:.4f}  stationary@5%={ar.get('is_stationary_5pct')}"
            )
        intra["ret"] = intra["basket_ret"]
    else:
        eng = StatArbEngine(window=z_window, entry_z=z_entry, exit_z=z_exit)
        intra = eng.transform(intra_y)
        intra["ret"] = intra["close"].pct_change()

    orch = StrategyOrchestrator(regime_shift_sessions=regime_shift_sessions)
    intra = orch.merge_and_gate(intra, daily_regime, trade_date_col="trade_date")
    intra["standalone_ret"] = vectorized_strategy_returns(intra, "micro_position")
    intra["filtered_ret"] = vectorized_strategy_returns(intra, "orchestrated_position")

    return intra


def plot_curves(intra: pd.DataFrame, *, title: str, save_path: str | None = None) -> None:
    try:
        import matplotlib.pyplot as plt
    except ImportError:
        print("matplotlib not installed; skip plot. pip install matplotlib", file=sys.stderr)
        return

    sta = equity_curve(intra["standalone_ret"])
    flt = equity_curve(intra["filtered_ret"])

    fig, ax = plt.subplots(figsize=(10, 5))
    ax.plot(sta.index, sta.values, label="Standalone mean-reversion", linewidth=1.2)
    ax.plot(flt.index, flt.values, label="Momentum-filtered", linewidth=1.2)
    ax.axhline(0.0, color="gray", linewidth=0.5)
    ax.set_title(title)
    ax.set_ylabel("Cumulative return (compounded)")
    ax.legend(loc="best")
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    if save_path:
        fig.savefig(save_path, dpi=150)
        print(f"💾 Saved plot → {save_path}")
    else:
        plt.show()


def find_cointegrated_pairs_with_fallback(
    us: Any,
    data_dict: dict[str, pd.DataFrame],
) -> tuple[pd.DataFrame, str | None]:
    """
    Universe scan often returns zero rows: Holm–Bonferroni across ~5k pairs is strict,
    and Hurst / half-life filters remove many survivors. Retry with looser thresholds.
    """
    attempts: list[tuple[str, dict[str, Any]]] = [
        (
            "p<0.05 (Holm), Hurst<0.45, half-life 1–15d",
            {
                "p_value_threshold": 0.05,
                "hurst_max": 0.45,
                "half_life_min": 1.0,
                "half_life_max": 15.0,
            },
        ),
        (
            "p<0.10 (Holm), same tradability",
            {
                "p_value_threshold": 0.10,
                "hurst_max": 0.45,
                "half_life_min": 1.0,
                "half_life_max": 15.0,
            },
        ),
        (
            "p<0.10, Hurst<0.55, half-life 0.5–40d",
            {
                "p_value_threshold": 0.10,
                "hurst_max": 0.55,
                "half_life_min": 0.5,
                "half_life_max": 40.0,
            },
        ),
        (
            "p<0.20, Hurst<0.60, half-life 0.5–120d",
            {
                "p_value_threshold": 0.20,
                "hurst_max": 0.60,
                "half_life_min": 0.5,
                "half_life_max": 120.0,
            },
        ),
    ]
    for label, kw in attempts:
        pairs = us.find_cointegrated_pairs(data_dict, **kw)
        if not pairs.empty:
            return pairs, label
    return pd.DataFrame(), None


def summarize(name: str, r: pd.Series, *, bars_per_year: float = 252.0) -> None:
    """
    Simple mean/std Sharpe on a return series.

    Use ``bars_per_year=252`` for daily returns (default). For ~hourly intraday bars,
    pass e.g. ``252 * 6`` as a rough annualization factor.
    """
    r = r.fillna(0.0).astype(np.float64)
    if len(r) < 2 or r.std(ddof=1) < 1e-12:
        print(f"  {name}: n={len(r)} (insufficient for Sharpe)")
        return
    mu = float(r.mean())
    sd = float(r.std(ddof=1))
    sharpe = mu / sd * np.sqrt(float(bars_per_year)) if sd > 0 else float("nan")
    cum = float((1.0 + r).prod() - 1.0)
    print(f"  {name}: mean_bar={mu:.6f}  std={sd:.6f}  approx_sharpe*={sharpe:.3f}  cum={cum:.4f}")


def _print_cumulative_curve_diagnostics(name: str, cum: pd.Series, *, bars_per_year: float = 252.0) -> None:
    """
    Stats from a compounded cumulative return column (e.g. ensemble_df['macro_cumulative_ret']).
    Daily returns are implied via pct_change of (1+cum). Discrete trade counts are not defined.
    """
    cum = cum.astype(np.float64)
    if cum.empty:
        print(f"  {name}: empty series")
        return
    final_cum = float(cum.iloc[-1])
    daily = (1.0 + cum).pct_change().fillna(0.0).astype(np.float64)
    n = int(len(daily))
    arr = daily.to_numpy(dtype=np.float64)
    pos_days = arr[arr > 0]
    neg_days = arr[arr < 0]
    wr = float((arr > 0).mean()) if n else float("nan")
    aw = float(pos_days.mean()) if pos_days.size else float("nan")
    al = float(neg_days.mean()) if neg_days.size else float("nan")
    mu = float(daily.mean()) if n else float("nan")
    sd = float(daily.std(ddof=1)) if n > 1 else float("nan")
    sharpe = mu / sd * np.sqrt(float(bars_per_year)) if sd > 1e-12 else float("nan")
    eq = np.cumprod(1.0 + arr)
    peak = np.maximum.accumulate(eq)
    dd = eq / peak - 1.0
    mdd = float(dd.min()) if len(dd) else float("nan")
    print(
        f"  {name}: final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}  "
        f"max_drawdown={mdd * 100:.2f}%"
    )
    print(
        f"       trading_days={n}  daily_win_rate={wr * 100:.2f}%  "
        f"avg_win_day={aw * 100:.4f}%  avg_loss_day={al * 100:.4f}%"
    )


def _cumulative_series_metrics_dict(cum: pd.Series, *, bars_per_year: float = 252.0) -> dict[str, float | None]:
    """
    Stats from a compounded cumulative return series for investor_metrics.json.

    Percent fields are percentage points (e.g. 87.5 means +87.5% total return; -22.0 means -22% max DD).
    """
    empty = {
        "total_return_pct": None,
        "annualized_return_pct": None,
        "sharpe_approx": None,
        "max_drawdown_pct": None,
    }
    cum = cum.astype(np.float64)
    if cum.empty or len(cum) < 2:
        return empty.copy()
    final_cum = float(cum.iloc[-1])
    if not np.isfinite(final_cum):
        return empty.copy()
    daily = (1.0 + cum).pct_change().fillna(0.0).astype(np.float64)
    n = int(len(daily))
    arr = daily.to_numpy(dtype=np.float64)
    mu = float(daily.mean())
    sd = float(daily.std(ddof=1)) if n > 1 else float("nan")
    sharpe = mu / sd * np.sqrt(float(bars_per_year)) if sd > 1e-12 else float("nan")
    eq = np.cumprod(1.0 + arr)
    peak = np.maximum.accumulate(eq)
    dd = eq / peak - 1.0
    mdd = float(dd.min())
    years = n / float(bars_per_year)
    ann: float | None = None
    if years > 1e-6 and final_cum > -1.0:
        ann_v = ((1.0 + final_cum) ** (1.0 / years) - 1.0) * 100.0
        ann = float(ann_v) if np.isfinite(ann_v) else None
    return {
        "total_return_pct": round(final_cum * 100.0, 4),
        "annualized_return_pct": round(ann, 4) if ann is not None else None,
        "sharpe_approx": round(float(sharpe), 4) if np.isfinite(sharpe) else None,
        "max_drawdown_pct": round(mdd * 100.0, 4) if np.isfinite(mdd) else None,
    }


def _investor_metrics_row(name: str, cum: pd.Series, *, enabled: bool) -> dict[str, Any]:
    row: dict[str, Any] = {"name": name}
    if not enabled:
        row.update(
            {
                "total_return_pct": None,
                "annualized_return_pct": None,
                "sharpe_approx": None,
                "max_drawdown_pct": None,
            }
        )
        return row
    row.update(_cumulative_series_metrics_dict(cum))
    return row


def _leg_activity_from_cum(cum: pd.Series) -> dict[str, Any]:
    """Daily PnL-style stats from a cumulative return leg (unweighted or blended)."""
    empty = {
        "trading_days": None,
        "days_in_trade": None,
        "pct_time_in_trade_pct": None,
        "num_trades": None,
        "avg_win_day_pct": None,
        "avg_loss_day_pct": None,
    }
    cum = cum.astype(np.float64)
    if cum.empty or len(cum) < 2:
        return empty.copy()
    daily = (1.0 + cum).pct_change().fillna(0.0).astype(np.float64)
    n = int(len(daily))
    if n == 0:
        return empty.copy()
    arr = daily.to_numpy(dtype=np.float64)
    nonzero = int(np.sum(np.abs(arr) > 1e-10))
    pos = arr[arr > 0]
    neg = arr[arr < 0]
    aw = float(pos.mean()) * 100.0 if pos.size else float("nan")
    al = float(neg.mean()) * 100.0 if neg.size else float("nan")
    return {
        "trading_days": n,
        "days_in_trade": nonzero,
        "pct_time_in_trade_pct": round(100.0 * nonzero / n, 2) if n else None,
        "num_trades": None,
        "avg_win_day_pct": round(aw, 4) if np.isfinite(aw) else None,
        "avg_loss_day_pct": round(al, 4) if np.isfinite(al) else None,
    }


def _longest_underwater_trading_days(cum: pd.Series) -> int:
    """Longest streak of closes below the running high-water mark (approx. drawdown pain)."""
    x = (1.0 + cum.astype(np.float64).fillna(0.0)).to_numpy()
    if x.size == 0:
        return 0
    peak = np.maximum.accumulate(x)
    underwater = x < peak - 1e-12
    max_streak = streak = 0
    for u in underwater:
        if u:
            streak += 1
            max_streak = max(max_streak, streak)
        else:
            streak = 0
    return int(max_streak)


def _strategy_stat_side(
    daily: pd.Series,
    cum: pd.Series,
    spy_daily: pd.Series,
    *,
    is_benchmark: bool,
) -> dict[str, Any]:
    """One column of 'Strategy statistics' (blended or SPY) for investor HTML table."""
    d = daily.astype(np.float64).fillna(0.0)
    spy_b = spy_daily.astype(np.float64).reindex(d.index).fillna(0.0)
    n = int(len(d))
    sd = float(d.std(ddof=1)) if n > 1 else float("nan")
    ann_vol_pct = float(sd * np.sqrt(252.0) * 100.0) if np.isfinite(sd) else float("nan")
    mu = float(d.mean()) if n else float("nan")
    sharpe = float(mu / sd * np.sqrt(252.0)) if sd > 1e-12 and np.isfinite(sd) else float("nan")
    arr = d.to_numpy(dtype=np.float64)
    win_rate = float((arr > 0).mean() * 100.0) if n else float("nan")
    try:
        di = pd.to_datetime(d.index)
        s_m = pd.Series(arr, index=di).sort_index()
        monthly = (1.0 + s_m).resample("ME").prod() - 1.0
        pos_m = float((monthly > 0).mean() * 100.0) if len(monthly) else float("nan")
    except Exception:
        pos_m = float("nan")

    fin = _cumulative_series_metrics_dict(cum)
    mdd = fin.get("max_drawdown_pct")
    ann = fin.get("annualized_return_pct")
    total = fin.get("total_return_pct")

    u_days = _longest_underwater_trading_days(cum)
    dur_m = max(1, int(round(u_days / 21.0)))
    dd_dur = f"{dur_m} Months"

    if is_benchmark:
        corr = 1.0
        dcorr = 1.0
    else:
        a = d.to_numpy(dtype=np.float64)
        b = spy_b.to_numpy(dtype=np.float64)
        if n > 2 and np.std(a, ddof=1) > 1e-12 and np.std(b, ddof=1) > 1e-12:
            corr = float(np.corrcoef(a, b)[0, 1])
        else:
            corr = float("nan")
        mask = b < 0
        if int(np.sum(mask)) > 5:
            aa = a[mask]
            bb = b[mask]
            if np.std(aa, ddof=1) > 1e-12 and np.std(bb, ddof=1) > 1e-12:
                dcorr = float(np.corrcoef(aa, bb)[0, 1])
            else:
                dcorr = float("nan")
        else:
            dcorr = float("nan")

    return {
        "average_annual_return_pct": round(ann, 2) if ann is not None else None,
        "standard_deviation_pct": round(ann_vol_pct, 1) if np.isfinite(ann_vol_pct) else None,
        "sharpe_ratio": round(sharpe, 2) if np.isfinite(sharpe) else None,
        "correlation_sp500": round(corr, 2) if np.isfinite(corr) else None,
        "down_correlation_sp500": round(dcorr, 2) if np.isfinite(dcorr) else None,
        "biggest_drawdown_pct": mdd,
        "biggest_drawdown_duration": dd_dur,
        "win_rate_pct": round(win_rate, 0) if np.isfinite(win_rate) else None,
        "short_long_vol_breakdown": None,
        "pct_positive_months": round(pos_m, 0) if np.isfinite(pos_m) else None,
        "total_return_pct": total,
    }


def _short_long_vol_breakdown_ls(ls_diag: dict[str, Any]) -> str | None:
    """Rough exposure-style split if AQR diagnostics present (not true vol split)."""
    if not isinstance(ls_diag, dict) or ls_diag.get("engine") != "cross_sectional_momentum":
        return None
    dl = ls_diag.get("days_with_long_exposure")
    ds = ls_diag.get("days_with_short_exposure")
    td = ls_diag.get("trading_days")
    if not isinstance(dl, int) or not isinstance(ds, int) or not isinstance(td, int) or td <= 0:
        return None
    # Days with any long vs any short exposure (overlap possible; illustrative only).
    p_long = 100.0 * dl / td
    p_short = 100.0 * ds / td
    return f"{p_long:.0f}% | {p_short:.0f}%"


def _downsample_equity_curve(
    idx: pd.Index,
    ensemble_cum: np.ndarray,
    spy_cum: np.ndarray | None,
    *,
    max_points: int = 520,
) -> dict[str, Any]:
    """JSON-friendly series for HTML chart (cumulative return in percent points)."""
    n = len(idx)
    if n == 0:
        return {"dates": [], "ensemble_cumulative_pct": [], "spy_cumulative_pct": []}
    step = max(1, int(np.ceil(n / float(max_points))))
    sel = np.arange(0, n, step, dtype=int)
    if sel[-1] != n - 1:
        sel = np.unique(np.append(sel, n - 1))
    dates: list[str] = []
    ens_pct: list[float | None] = []
    spy_pct: list[float | None] = []
    for i in sel:
        dates.append(pd.Timestamp(idx[i]).strftime("%Y-%m-%d"))
        ens_pct.append(round(float(ensemble_cum[i]) * 100.0, 6))
        if spy_cum is not None and len(spy_cum) > i:
            spy_pct.append(round(float(spy_cum[i]) * 100.0, 6))
        else:
            spy_pct.append(None)
    return {
        "dates": dates,
        "ensemble_cumulative_pct": ens_pct,
        "spy_cumulative_pct": spy_pct,
    }


def _json_sanitize(obj: Any) -> Any:
    """Make structure safe for json.dump(..., allow_nan=False)."""
    if isinstance(obj, dict):
        return {str(k): _json_sanitize(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_json_sanitize(v) for v in obj]
    if isinstance(obj, float):
        if obj != obj or obj in (float("inf"), float("-inf")):
            return None
        return obj
    if isinstance(obj, (np.floating,)):
        x = float(obj)
        if x != x or x in (float("inf"), float("-inf")):
            return None
        return x
    if isinstance(obj, (np.integer,)):
        return int(obj)
    return obj


def _export_ensemble_investor_metrics_json(
    out_path: str,
    *,
    ensemble_df: pd.DataFrame,
    use_sector_sleeve: bool,
    use_def_sleeve: bool,
    use_dip_sleeve: bool,
    spy_bnh_cum: pd.Series | None,
    args: argparse.Namespace,
    ensemble_manager: Any,
    ls_diagnostics: dict[str, Any],
) -> None:
    """Write investor_metrics JSON for HTML/PDF reports (curves, weights, activity)."""
    idx = ensemble_df.index
    try:
        i0 = pd.Timestamp(idx[0])
        i1 = pd.Timestamp(idx[-1])
        as_of = i1.strftime("%B %d, %Y")
        period_lbl = f"{i0.strftime('%Y-%m-%d')} — {i1.strftime('%Y-%m-%d')} ({len(idx)} trading days)"
    except Exception:
        as_of = "—"
        period_lbl = "—"

    rows: list[dict[str, Any]] = [
        _investor_metrics_row(
            "Blended strategy (full ensemble)",
            ensemble_df["ensemble_cumulative_ret"],
            enabled=True,
        ),
        _investor_metrics_row(
            "Tactical All Weather core (macro book)",
            ensemble_df["macro_cumulative_ret"],
            enabled=True,
        ),
        _investor_metrics_row(
            "Market-neutral equity momentum sleeve",
            ensemble_df["ls_cumulative_ret"],
            enabled=True,
        ),
        _investor_metrics_row(
            "Defensive Treasury tilt (risk-off overlay)",
            ensemble_df["defensive_cumulative_ret"],
            enabled=use_def_sleeve,
        ),
        _investor_metrics_row(
            "Sector rotation sleeve",
            ensemble_df["sector_cumulative_ret"],
            enabled=use_sector_sleeve,
        ),
    ]
    if use_dip_sleeve:
        rows.append(
            _investor_metrics_row(
                "Buy-the-dip sleeve (unweighted leg)",
                ensemble_df["ls_short_cumulative_ret"],
                enabled=True,
            )
        )

    wm = float(ensemble_manager.weight_macro)
    wa = float(ensemble_manager.weight_alpha)
    wd = float(ensemble_manager.weight_defensive) if use_def_sleeve else 0.0
    ws = float(ensemble_manager.weight_sector) if use_sector_sleeve else 0.0
    wls = float(ensemble_manager.weight_ls_short) if use_dip_sleeve else 0.0
    w_sum = wm + wa + wd + ws + wls
    w_sum = w_sum if w_sum > 1e-12 else 1.0

    portfolio_blend = {
        "description": (
            "Model weights multiply each sleeve's daily return before summing to the blended ensemble. "
            "Shares sum to 100% of the weight budget shown (gross exposure can exceed 100% capital)."
        ),
        "weights_raw": {
            "macro_book": wm,
            "ls_alpha": wa,
            "defensive_tlt": wd,
            "sector_rotation": ws,
            "buy_the_dip": wls,
        },
        "weight_share_pct": {
            "macro_book": round(100.0 * wm / w_sum, 2),
            "ls_alpha": round(100.0 * wa / w_sum, 2),
            "defensive_tlt": round(100.0 * wd / w_sum, 2),
            "sector_rotation": round(100.0 * ws / w_sum, 2),
            "buy_the_dip": round(100.0 * wls / w_sum, 2),
        },
    }

    cum_specs: list[tuple[str, str | None, bool]] = [
        ("ensemble_cumulative_ret", None, True),
        ("macro_cumulative_ret", "macro_book", True),
        ("ls_cumulative_ret", "ls_alpha", True),
        ("defensive_cumulative_ret", "defensive_tlt", use_def_sleeve),
        ("sector_cumulative_ret", "sector_rotation", use_sector_sleeve),
    ]
    if use_dip_sleeve:
        cum_specs.append(("ls_short_cumulative_ret", "buy_the_dip", True))

    for i, row in enumerate(rows):
        if i >= len(cum_specs):
            break
        col, wkey, sleeve_on = cum_specs[i]
        if col in ensemble_df.columns:
            cum_s = ensemble_df[col].astype(np.float64)
            row.update(_leg_activity_from_cum(cum_s))
        if i == 0:
            row["model_weight"] = None
            row["weight_share_pct"] = None
        elif wkey:
            row["model_weight"] = round(float(portfolio_blend["weights_raw"][wkey]), 4) if sleeve_on else None
            row["weight_share_pct"] = (
                portfolio_blend["weight_share_pct"][wkey] if sleeve_on else None
            )
        if i == 2 and isinstance(ls_diagnostics, dict):
            if ls_diagnostics.get("engine") == "cross_sectional_momentum":
                lt = ls_diagnostics.get("approx_long_leg_trades")
                st = ls_diagnostics.get("approx_short_leg_trades")
                if lt is not None and st is not None:
                    row["num_trades"] = int(lt) + int(st)
                    row["num_trades_note"] = (
                        "AQR L/S: sum of approx. completed round-turns per leg; not broker ticket count."
                    )
                dl = ls_diagnostics.get("days_with_long_exposure")
                ds = ls_diagnostics.get("days_with_short_exposure")
                if isinstance(dl, int) and isinstance(ds, int):
                    row["days_with_long_exposure"] = dl
                    row["days_with_short_exposure"] = ds

    ens_arr = ensemble_df["ensemble_cumulative_ret"].astype(np.float64).to_numpy()
    spy_arr = (
        spy_bnh_cum.astype(np.float64).to_numpy()
        if spy_bnh_cum is not None and len(spy_bnh_cum) == len(ensemble_df)
        else None
    )
    equity_curve = _downsample_equity_curve(ensemble_df.index, ens_arr, spy_arr)

    strategy_statistics: dict[str, Any] | None = None
    try:
        if spy_bnh_cum is not None and len(spy_bnh_cum.index) == len(ensemble_df.index):
            ens_cum_s = ensemble_df["ensemble_cumulative_ret"].astype(np.float64)
            ens_d = (1.0 + ens_cum_s).pct_change().fillna(0.0).astype(np.float64)
            spy_c_s = spy_bnh_cum.astype(np.float64)
            spy_d = (1.0 + spy_c_s).pct_change().fillna(0.0).astype(np.float64)
            spy_d = spy_d.reindex(ens_d.index).fillna(0.0).astype(np.float64)
            strat_side = _strategy_stat_side(ens_d, ens_cum_s, spy_d, is_benchmark=False)
            bench_side = _strategy_stat_side(spy_d, spy_c_s, spy_d, is_benchmark=True)
            sl = _short_long_vol_breakdown_ls(ls_diagnostics)
            if sl:
                strat_side["short_long_vol_breakdown"] = sl
                strat_side["short_long_vol_breakdown_note"] = (
                    "Share of days with long vs short exposure on the L/S sleeve (illustrative); "
                    "not a true short/long volatility decomposition."
                )
            strategy_statistics = {
                "period_banner": period_lbl,
                "blended_column_label": "Blended strategy",
                "benchmark_column_label": "S&P 500 (SPY)",
                "blended": strat_side,
                "benchmark": bench_side,
            }
    except Exception:
        strategy_statistics = None

    bm_stats = (
        _cumulative_series_metrics_dict(spy_bnh_cum)
        if spy_bnh_cum is not None and len(spy_bnh_cum) >= 2
        else {
            "total_return_pct": None,
            "annualized_return_pct": None,
            "sharpe_approx": None,
            "max_drawdown_pct": None,
        }
    )
    ens_tr = rows[0].get("total_return_pct")
    b_tr = bm_stats.get("total_return_pct")
    excess: float | None = None
    if isinstance(ens_tr, (int, float)) and isinstance(b_tr, (int, float)):
        excess = round(float(ens_tr) - float(b_tr), 4)

    title = str(getattr(args, "investor_strategy_title", "") or "").strip()
    if not title:
        title = "Multi-sleeve systematic macro & equity (research simulation)"

    payload: dict[str, Any] = {
        "_units": (
            "total_return_pct and max_drawdown_pct are percentage points "
            "(e.g. 87.5 = +87.5% cumulative; -22.3 = -22.3% max drawdown)."
        ),
        "strategy_title": title,
        "as_of": as_of,
        "backtest_period": period_lbl,
        "currency": "USD (nominal)",
        "ensemble_vs_benchmark_total_return_pct": excess,
        "portfolio_blend": portfolio_blend,
        "equity_curve": equity_curve,
        "strategy_statistics": strategy_statistics,
        "rows": rows,
        "benchmark": {
            "name": "S&P 500 — SPY buy & hold",
            "description": (
                "Unmanaged U.S. large-cap equity via SPY, buy-and-hold over the same "
                "trading days as the ensemble simulation."
            ),
            "total_return_pct": bm_stats["total_return_pct"],
            "annualized_return_pct": bm_stats["annualized_return_pct"],
            "sharpe_approx": bm_stats["sharpe_approx"],
            "max_drawdown_pct": bm_stats["max_drawdown_pct"],
            "reinvestment_note": (
                "Uses daily `ret` from the research panel (aligned to ensemble dates); "
                "ensure your data source is total-return consistent if you rely on these figures."
            ),
        },
        "footnotes": [
            f"Auto-exported from ensemble: --daily-period {args.daily_period}, "
            f"L/S universe {args.ls_universe}, engine {args.ensemble_ls_engine}.",
            "Includes equity_curve (downsampled), portfolio_blend, and per-row activity stats for the HTML report.",
            "Sharpe ≈ mean/vol × sqrt(252) on daily returns implied from cumulative curves.",
            "Sleeves disabled in this run are shown as null metrics in `rows`.",
            "Hypothetical / simulated; not actual investor results.",
        ],
    }

    path = Path(out_path).expanduser()
    path.parent.mkdir(parents=True, exist_ok=True)
    safe = _json_sanitize(payload)
    with path.open("w", encoding="utf-8") as f:
        json.dump(safe, f, indent=2, allow_nan=False)
        f.write("\n")
    print(f"\n  Wrote investor metrics JSON → {path}")


def _print_monthly_returns_by_year(daily_ret: pd.Series, title: str) -> None:
    """
    Print calendar-month compound returns (%) in a year × month grid (Jan–Dec).
    """
    r = pd.Series(daily_ret.astype(np.float64), copy=True).dropna()
    if r.empty:
        return
    r.index = pd.to_datetime(r.index)
    if r.index.tz is not None:
        r.index = r.index.tz_localize(None)
    r = r.sort_index()

    monthly = (1.0 + r).resample("ME").prod() - 1.0
    if monthly.empty:
        return

    df = pd.DataFrame(
        {
            "ret": monthly.to_numpy(dtype=np.float64),
            "year": monthly.index.year,
            "month": monthly.index.month,
        }
    )
    pivot = df.pivot_table(index="year", columns="month", values="ret", aggfunc="first")
    pivot = pivot.reindex(columns=list(range(1, 13)))
    pivot.columns = [calendar.month_abbr[m] for m in range(1, 13)]
    pivot_pct = pivot * 100.0

    print(f"\n{title} — monthly returns by year (%)")
    print(pivot_pct.to_string(na_rep="    —   ", float_format=lambda x: f"{x:7.2f}"))


def _parse_daily_period_years(period: str) -> float:
    """Interpret e.g. '5y', '10y', 'max' as lookback years for universe_scanner."""
    s = str(period).strip().lower()
    if s in ("max", "maximum", "all"):
        # 30y so buy-the-dip / L/S panels can reach 2016+ with SMA200 warmup (yfinance ~30y cap).
        return 30.0
    if s.endswith("y"):
        try:
            return float(s[:-1])
        except Exception:
            return 10.0
    return 10.0


def _compute_daily_backtest_features(df: pd.DataFrame) -> pd.DataFrame:
    """Normalize close, ret, sma_200, aqr_mom for ensemble / ls-equity modes."""
    if df.empty:
        return df
    price_col = "close" if "close" in df.columns else ("Close" if "Close" in df.columns else "")
    if not price_col:
        raise KeyError(f"Expected 'close' or 'Close' column, got: {list(df.columns)}")
    close = df[price_col].astype(np.float64)
    out = df.copy()
    if "close" not in out.columns:
        out["close"] = close
    out["ret"] = close.pct_change()
    out["sma_200"] = close.rolling(window=200, min_periods=200).mean()
    out["aqr_mom"] = close.shift(21) / close.shift(252) - 1.0
    return out


def _ensure_ml_panel_for_dip(df: pd.DataFrame) -> pd.DataFrame:
    """Ensure ``ret`` for :class:`BuyTheDipSleeve` on ML OHLCV panels (trend SMA built from close in-engine)."""
    if "close" not in df.columns:
        raise KeyError("close required")
    out = df.copy()
    if "ret" not in out.columns:
        out["ret"] = out["close"].astype(np.float64).pct_change()
    return out


def _normalize_ohlcv_for_ml(df: pd.DataFrame) -> pd.DataFrame:
    """Lowercase OHLCV; ``close`` prefers Adj Close when present (yfinance)."""
    out = df.copy()
    if isinstance(out.columns, pd.MultiIndex):
        out.columns = out.columns.droplevel(1)
    out = out.rename(columns={c: str(c).lower() for c in out.columns})
    if "adj close" in out.columns:
        out["close"] = out["adj close"].astype(np.float64)
    if "close" not in out.columns:
        raise KeyError("ML path requires Close/close in OHLCV.")
    for c in ("high", "volume"):
        if c not in out.columns:
            raise KeyError(f"ML path requires {c} in OHLCV.")
    return out[["close", "high", "volume"]].astype(np.float64)


def _load_sp500_ml_panel(daily_period: str, max_tickers: int) -> tuple[dict[str, pd.DataFrame], dict[str, str]]:
    """Download S&P 500 (Wikipedia) daily OHLCV + sector map for :mod:`ml_momentum_engine`."""
    from RenTech.strategy_stack.ml_momentum_engine import fetch_sp500_constituents_with_sectors

    import universe_scanner as us  # type: ignore[import-not-found]

    tab = fetch_sp500_constituents_with_sectors()
    tickers = tab["ticker"].astype(str).tolist()
    if max_tickers and max_tickers > 0:
        tickers = tickers[: int(max_tickers)]
    sub = tab[tab["ticker"].astype(str).isin(tickers)]
    sector_map = dict(zip(sub["ticker"].astype(str), sub["sector"].astype(str)))
    for t in tickers:
        sector_map.setdefault(t, "Unknown")

    years = _parse_daily_period_years(daily_period)
    print(f"🔍 Downloading {len(tickers)} S&P 500 names (daily OHLCV) …")
    raw = us.download_and_cache_data(
        tickers,
        timeframe="1d",
        lookback_years=years,
    )
    equity_dict: dict[str, pd.DataFrame] = {}
    for t, df in raw.items():
        if df is None or df.empty:
            continue
        try:
            equity_dict[t] = _normalize_ohlcv_for_ml(df)
        except Exception:
            continue
    return equity_dict, sector_map


def _load_aqr_equity_dict(
    daily_period: str,
    *,
    universe: str,
    max_tickers: int = 0,
    refresh_cache: bool = False,
) -> dict[str, pd.DataFrame]:
    """
    Daily panels with ``close``, ``ret``, ``sma_200``, ``aqr_mom`` for :class:`CrossSectionalMomentum`.
    """
    import universe_scanner as us  # type: ignore[import-not-found]

    years = _parse_daily_period_years(daily_period)
    if universe == "sp100":
        tickers = us.get_sp100_tickers(universe="sp100")
        label = "S&P 100"
    elif universe == "sp500":
        from RenTech.strategy_stack.ml_momentum_engine import fetch_sp500_constituents_with_sectors

        tab = fetch_sp500_constituents_with_sectors()
        tickers = tab["ticker"].astype(str).tolist()
        if max_tickers and max_tickers > 0:
            tickers = tickers[: int(max_tickers)]
        label = "S&P 500"
    else:
        raise ValueError(f"universe must be sp100 or sp500, got {universe!r}")

    print(f"🔍 Downloading {label} equity universe ({len(tickers)} names) …")
    equity_raw = us.download_and_cache_data(
        tickers,
        timeframe="1d",
        lookback_years=years,
        max_age_hours=0.0 if refresh_cache else 24.0,
    )
    equity_dict: dict[str, pd.DataFrame] = {}
    for t, df in equity_raw.items():
        if df is None or df.empty:
            continue
        df2 = df.copy()
        df2.index = pd.to_datetime(df2.index).tz_localize(None)
        df2 = df2.sort_index()
        equity_dict[t] = _compute_daily_backtest_features(df2)
    return equity_dict


def _wiki_sector_map_for_tickers(tickers: set[str]) -> dict[str, str]:
    """GICS sector labels from Wikipedia S&P 500 table; missing tickers map to 'Unknown'."""
    from RenTech.strategy_stack.ml_momentum_engine import fetch_sp500_constituents_with_sectors

    tab = fetch_sp500_constituents_with_sectors()
    wiki = dict(zip(tab["ticker"].astype(str), tab["sector"].astype(str)))
    return {t: wiki.get(t, "Unknown") for t in tickers}


def _load_sector_etf_dict(daily_period: str) -> dict[str, pd.DataFrame]:
    """11 SPDR sector ETFs with ``close``, ``ret``, ``sma_200``, ``aqr_mom`` (same as macro/L/S panels)."""
    from RenTech.strategy_stack.multi_strategy_manager import SPDR_SECTOR_TICKERS

    dl = DataLoader()
    out: dict[str, pd.DataFrame] = {}
    for t in SPDR_SECTOR_TICKERS:
        daily = dl.fetch_daily(t, period=daily_period)
        if daily.empty:
            raise RuntimeError(f"No daily data for sector ETF {t} (check period / ticker)")
        out[t] = _compute_daily_backtest_features(daily)
    return out


def _load_hourly_swing_universe(
    intraday_period: str,
    interval: str,
    *,
    universe: str,
    max_tickers: int,
    tickers_csv: str,
) -> dict[str, pd.DataFrame]:
    """
    Hourly OHLCV panels with ``trade_date`` for :mod:`swing_breakout_hourly`.
    Yahoo limits intraday history; prefer ``730d`` for 60m bars.
    """
    import universe_scanner as us  # type: ignore[import-not-found]

    if tickers_csv.strip():
        tickers = [x.strip().upper() for x in tickers_csv.split(",") if x.strip()]
        label = "custom list"
    elif universe == "macro-etfs":
        tickers = list(HOURLY_SWING_MACRO_ETF_TICKERS)
        label = "macro ETFs (FX, oil, gold/silver, Treasuries, commodities, VXX/SVXY)"
    elif universe == "sp100":
        tickers = us.get_sp100_tickers(universe="sp100")
        label = "S&P 100"
    elif universe == "sp500":
        from RenTech.strategy_stack.ml_momentum_engine import fetch_sp500_constituents_with_sectors

        tab = fetch_sp500_constituents_with_sectors()
        tickers = tab["ticker"].astype(str).tolist()
        label = "S&P 500"
    else:
        raise ValueError(
            f"universe must be macro-etfs, sp100, sp500, or use --hourly-swing-tickers; got {universe!r}"
        )

    if max_tickers and max_tickers > 0:
        tickers = tickers[: int(max_tickers)]

    iv = "60m" if interval in ("1h", "60m") else interval
    dl = DataLoader()
    out: dict[str, pd.DataFrame] = {}
    print(f"🔍 Downloading hourly ({iv}) for {len(tickers)} names ({label}, {intraday_period}) …")
    for t in tickers:
        try:
            raw = dl.fetch_intraday(t, interval=iv, period=intraday_period)
        except Exception:
            continue
        if raw is None or raw.empty:
            continue
        try:
            h = dl.align_to_trading_days(raw)
        except Exception:
            continue
        if len(h) < 50:
            continue
        out[t] = h
    return out


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument(
        "--mode",
        default="run",
        choices=(
            "run",
            "scan",
            "pipeline",
            "swing",
            "swing-sector",
            "ls-equity",
            "ls-equity-dip",
            "ml-equity",
            "sector-etfs",
            "defensive-mr",
            "ensemble",
            "hourly-swing-breakout",
        ),
        help=(
            "Execution mode: run (single backtest), scan (find pairs only), pipeline (scan+optimize+OOS backtest), "
            "swing (daily CRSI pullback), swing-sector (Tactical All Weather macro book only), "
            "ls-equity (monthly AQR L/S; see --ls-universe), "
            "ls-equity-dip (RSI dip in SMA200 uptrend, fixed hold; see --ensemble-dip-*), "
            "ml-equity (S&P 500 XGBoost walk-forward L/S only), "
            "sector-etfs (SPDR sector ETF momentum rotation only), "
            "defensive-mr (SPY return panic z-score → TLT tilt; standalone), "
            "ensemble (macro + L/S + optional sleeves; see flags), "
            "hourly-swing-breakout (ETFs: fractal swing breakouts on hourly bars; --hourly-swing-blend equal|inv_vol|winners_equal; see --hourly-swing-*)."
        ),
    )
    p.add_argument("--regime-ticker", default="SPY")
    p.add_argument("--trade-ticker", default="QQQ")
    p.add_argument("--daily-period", default="5y")
    p.add_argument("--intraday-period", default="730d")
    p.add_argument("--interval", default="1h", choices=("1h", "60m", "15m", "30m"))
    p.add_argument("--sma", type=int, default=200)
    p.add_argument("--aqr-lookback", type=int, default=252)
    p.add_argument("--aqr-skip", type=int, default=21)
    p.add_argument("--z-window", type=int, default=20)
    p.add_argument("--z-entry", type=float, default=2.0)
    p.add_argument("--z-exit", type=float, default=0.0)
    p.add_argument("--regime-shift-days", type=int, default=0, help="Lag regime by N calendar days")
    p.add_argument(
        "--hedge-ticker",
        default="",
        help="If set, pair stat-arb: causal OLS hedge, z on spread, PnL on basket_ret (needs statsmodels)",
    )
    p.add_argument(
        "--hedge-window",
        type=int,
        default=0,
        help="Hedge refit: 0 = expanding OLS on all past bars; N>0 = rolling N bars",
    )
    p.add_argument(
        "--min-hedge-obs",
        type=int,
        default=30,
        help="Minimum prior bars required before first causal spread / hedge",
    )
    # Swing engine (CRSI pullback)
    p.add_argument("--trend-sma", type=int, default=200, help="SwingEngine: trend SMA window")
    p.add_argument("--rsi-window", type=int, default=2, help="SwingEngine: RSI window")
    p.add_argument("--rsi-threshold", type=float, default=10.0, help="SwingEngine: RSI entry threshold")
    p.add_argument("--exit-sma", type=int, default=5, help="SwingEngine: exit SMA window")
    p.add_argument(
        "--inverse-sp500-ticker",
        type=str,
        default="SH",
        help="Tickers for --mode swing-sector: inverse S&P 500 ETF to include alongside the sector ETFs (default: SH).",
    )
    p.add_argument(
        "--disable-inverse-sp500",
        action="store_true",
        help="Disable inclusion of the inverse S&P 500 ETF in --mode swing-sector.",
    )
    p.add_argument(
        "--sector-tickers",
        type=str,
        default="",
        help="Comma-separated sector ETF tickers for --mode swing-sector. If empty, uses default 11 SPDR Select Sector ETFs.",
    )
    p.add_argument(
        "--rp-vol-window",
        type=int,
        default=120,
        help="Risk parity volatility window (bars) for --mode swing-sector (inverse-vol weights, shifted by 1 bar).",
    )
    p.add_argument(
        "--rp-kill-switch-max-dd",
        type=float,
        default=-1e9,
        help=(
            "Per-asset drawdown kill switch max dd threshold for --mode swing-sector. "
            "Set very negative to effectively disable (default: -1e9)."
        ),
    )
    p.add_argument("--save-plot", default="", help="PNG path (empty = plt.show())")
    p.add_argument(
        "--export-investor-metrics-json",
        default="",
        help=(
            "ensemble only: append to your full ensemble command (same flags as a normal run); "
            "writes metrics JSON for scripts/build_investor_strategy_pdf.py "
            "(e.g. docs/investor_metrics.json). The literal word '...' is not valid here."
        ),
    )
    p.add_argument(
        "--investor-strategy-title",
        default="Multi-sleeve systematic macro & equity (research simulation)",
        help="ensemble: strategy_title in the JSON produced by --export-investor-metrics-json.",
    )
    p.add_argument(
        "--ensemble-ls-engine",
        choices=("aq", "ml"),
        default="aq",
        help="ensemble: L/S sleeve — aq (AQR rank + regime/vol) or ml (XGBoost on S&P 500 OHLCV).",
    )
    p.add_argument(
        "--ensemble-sector-sleeve",
        action="store_true",
        help="ensemble: add SPDR sector ETF sleeve (top-k by 12-1 momentum); weight via --ensemble-sector-weight.",
    )
    p.add_argument(
        "--ensemble-sector-top-k",
        type=int,
        default=3,
        help="ensemble + sector-etfs: hold this many sector ETFs each month (equal weight among them).",
    )
    p.add_argument(
        "--ensemble-sector-weight",
        type=float,
        default=0.15,
        help="ensemble: blend weight on sector rotation daily returns (0 disables even if --ensemble-sector-sleeve).",
    )
    p.add_argument(
        "--ensemble-defensive-mr",
        action="store_true",
        help="ensemble: add defensive mean-reversion sleeve (SPY daily-return z → TLT when panicked).",
    )
    p.add_argument(
        "--ensemble-defensive-weight",
        type=float,
        default=0.12,
        help="ensemble: blend weight on defensive MR daily returns (0 disables even if --ensemble-defensive-mr).",
    )
    p.add_argument(
        "--defensive-z-window",
        type=int,
        default=20,
        help="defensive-mr / ensemble: rolling days for SPY return z-score.",
    )
    p.add_argument(
        "--defensive-entry-z",
        type=float,
        default=2.0,
        help="defensive-mr / ensemble: activate TLT tilt when SPY return z <= -this (positive number).",
    )
    p.add_argument(
        "--defensive-exit-z",
        type=float,
        default=0.5,
        help="defensive-mr / ensemble: clear tilt when SPY return z >= -this (e.g. 0.5 → exit at z >= -0.5).",
    )
    p.add_argument(
        "--defensive-tlt-weight",
        type=float,
        default=1.0,
        help="defensive-mr / ensemble: TLT weight within the sleeve when active (remainder cash at rf).",
    )
    p.add_argument(
        "--ensemble-dip-sleeve",
        action="store_true",
        help="ensemble: add **buy-the-dip** sleeve (RSI washout in SMA uptrend, ATR-ranked, fixed hold).",
    )
    p.add_argument(
        "--ensemble-dip-weight",
        type=float,
        default=0.12,
        help="ensemble: blend weight on buy-the-dip sleeve (0 disables even if --ensemble-dip-sleeve).",
    )
    p.add_argument(
        "--ensemble-dip-rsi-period",
        type=int,
        default=5,
        help="ensemble / ls-equity-dip: RSI period (Wilder smoothing).",
    )
    p.add_argument(
        "--ensemble-dip-rsi-max",
        type=float,
        default=20.0,
        help="ensemble / ls-equity-dip: enter when RSI is strictly below this (e.g. 20).",
    )
    p.add_argument(
        "--ensemble-dip-hold-days",
        type=int,
        default=5,
        help="ensemble / ls-equity-dip: hold each new entry for this many trading days (close-to-close).",
    )
    p.add_argument(
        "--ensemble-dip-atr-period",
        type=int,
        default=5,
        help="ensemble / ls-equity-dip: ATR period for normalized ATR (ATR/price) when ranking picks.",
    )
    p.add_argument(
        "--ensemble-dip-rank-by",
        choices=("atr_norm", "rsi"),
        default="atr_norm",
        help="ensemble / ls-equity-dip: rank new signals by normalized ATR (desc) or deeper RSI (asc).",
    )
    p.add_argument(
        "--ensemble-dip-trend-sma",
        type=int,
        default=200,
        help="ensemble / ls-equity-dip: SMA for dip-in-uptrend filter (close > SMA).",
    )
    p.add_argument(
        "--ensemble-dip-no-uptrend-filter",
        action="store_true",
        help="Buy dips even when close is below the trend SMA (default: require close > SMA).",
    )
    p.add_argument(
        "--ensemble-dip-spy-bull-only",
        action="store_true",
        help="Only hold dip basket when SPY is above its 200d SMA at week-end.",
    )
    p.add_argument(
        "--ensemble-dip-signal-mode",
        choices=("rsi", "pct_drop"),
        default="rsi",
        help="ensemble / ls-equity-dip: 'rsi' washout or 'pct_drop' (prior-day return threshold).",
    )
    p.add_argument(
        "--ensemble-dip-pct-drop-min",
        type=float,
        default=0.03,
        help="ensemble / ls-equity-dip: with pct_drop, enter when prior daily return <= -this (e.g. 0.03).",
    )
    p.add_argument(
        "--ensemble-dip-top-n",
        type=int,
        default=10,
        help="ensemble / ls-equity-dip: max new entries per day (among simultaneous signals).",
    )
    p.add_argument(
        "--ensemble-dip-vol-window",
        type=int,
        default=10,
        help="ensemble / ls-equity-dip: trailing return vol window for inverse-vol weights among active names.",
    )
    p.add_argument(
        "--ensemble-dip-weighting",
        choices=("equal", "inv_vol"),
        default="equal",
        help="ensemble / ls-equity-dip: equal weight across picks or inverse trailing vol.",
    )
    p.add_argument(
        "--ml-max-tickers",
        type=int,
        default=0,
        help="ml-equity / ensemble+ml: max names from the S&P 500 list (0 = all; smaller is faster).",
    )
    p.add_argument(
        "--ls-universe",
        choices=("sp100", "sp500"),
        default="sp100",
        help="ls-equity / ensemble+aq: cross-sectional universe (S&P 100 or S&P 500 from Wikipedia).",
    )
    p.add_argument(
        "--ls-max-tickers",
        type=int,
        default=0,
        help="With --ls-universe sp500: cap names (0 = full list). Ignored for sp100.",
    )
    p.add_argument(
        "--hourly-swing-universe",
        choices=("macro-etfs", "sp100", "sp500"),
        default="macro-etfs",
        help=(
            "hourly-swing-breakout: universe when --hourly-swing-tickers is empty. "
            "macro-etfs = FX/oil/gold/silver/Treasuries/commodities + VXX & inverse ST VIX (SVXY); see HOURLY_SWING_MACRO_ETF_TICKERS in main.py."
        ),
    )
    p.add_argument(
        "--hourly-swing-tickers",
        type=str,
        default="",
        help="hourly-swing-breakout: comma-separated tickers (overrides --hourly-swing-universe).",
    )
    p.add_argument(
        "--hourly-swing-max-tickers",
        type=int,
        default=0,
        help="hourly-swing-breakout: max symbols to load from universe (0 = all; cap for sp500/sp100 only).",
    )
    p.add_argument(
        "--hourly-swing-fractal-left",
        type=int,
        default=2,
        help="hourly-swing-breakout: fractal swing bars to the left of the pivot (Williams-style).",
    )
    p.add_argument(
        "--hourly-swing-fractal-right",
        type=int,
        default=2,
        help="hourly-swing-breakout: fractal swing bars to the right (confirmation lag; no lookahead).",
    )
    p.add_argument(
        "--hourly-swing-daily-atr",
        type=int,
        default=14,
        help="hourly-swing-breakout: Wilder daily ATR period (from daily OHLC aggregated from hourly bars).",
    )
    p.add_argument(
        "--hourly-swing-min-atr-mult",
        type=float,
        default=1.0,
        help="hourly-swing-breakout: min swing vs prior opposite swing ≥ this × prior-session daily ATR.",
    )
    p.add_argument(
        "--hourly-swing-hold-bars",
        type=int,
        default=10,
        help="hourly-swing-breakout: exit after this many hourly bars in the trade (time stop).",
    )
    p.add_argument(
        "--hourly-swing-long-only",
        action="store_true",
        help="hourly-swing-breakout: only trade upside breaks of swing highs.",
    )
    p.add_argument(
        "--hourly-swing-short-only",
        action="store_true",
        help="hourly-swing-breakout: only trade downside breaks of swing lows.",
    )
    p.add_argument(
        "--hourly-swing-close-confirm",
        action="store_true",
        help="hourly-swing-breakout: require close through the swing level (not only a wick).",
    )
    p.add_argument(
        "--hourly-swing-blend",
        choices=("equal", "inv_vol", "winners_equal"),
        default="equal",
        help="hourly-swing-breakout: portfolio blend (equal | inverse underlying vol | IS winners-only).",
    )
    p.add_argument(
        "--hourly-swing-vol-window",
        type=int,
        default=20,
        help="hourly-swing-breakout: inv_vol trailing std window on underlying hourly closes.",
    )
    p.add_argument(
        "--hourly-swing-winner-epsilon",
        type=float,
        default=0.0,
        help="hourly-swing-breakout: winners_equal keeps tickers with full-sample strat cum > this (in-sample).",
    )
    p.add_argument(
        "--ls-sector-neutral",
        action="store_true",
        help=(
            "ls-equity / ensemble+aq: split top_n long/short seats across GICS sectors (Wikipedia), "
            "ranking by momentum within each sector. Often lowers backtests vs default pool-wide rank "
            "because it adds weaker-trend names in sluggish sectors and trims the strongest names in "
            "leading sectors (classic momentum is intentionally cross-sectionally concentrated)."
        ),
    )
    p.add_argument(
        "--ml-target",
        choices=("excess", "total"),
        default="excess",
        help="ML sleeve: train on excess fwd return vs SPY (excess) or total stock fwd return (total).",
    )
    p.add_argument(
        "--ml-weighting",
        choices=("inv_vol", "equal"),
        default="inv_vol",
        help="ML sleeve: within long/short legs use inverse 20d vol (inv_vol) or equal 1/N (equal).",
    )
    p.add_argument(
        "--pair-y",
        default="",
        help="Pipeline only: trade leg ticker; use with --pair-x to skip the cointegration scan.",
    )
    p.add_argument(
        "--pair-x",
        default="",
        help="Pipeline only: hedge leg ticker; use with --pair-y to skip the cointegration scan.",
    )
    p.add_argument(
        "--portfolio-top-k",
        type=int,
        default=10,
        help="Pipeline: number of cointegrated pairs to optimize independently for portfolio aggregation.",
    )
    p.add_argument(
        "--portfolio-vol-window",
        type=int,
        default=120,
        help="Portfolio sizing: rolling volatility window (bars) used for inverse-vol weights.",
    )
    p.add_argument(
        "--portfolio-max-dd",
        type=float,
        default=-0.05,
        help="Portfolio kill-switch: max drawdown threshold per pair (e.g. -0.05 for -5%).",
    )
    p.add_argument(
        "--portfolio-save-plot",
        default="",
        help="PNG path for portfolio plot (empty = plt.show()).",
    )
    args = p.parse_args()

    os.chdir(_REPO_ROOT)

    if args.mode == "run":
        print("📥 Loading data (yfinance) …")
        hedge = args.hedge_ticker.strip() or None
        intra = run_pipeline(
            regime_ticker=args.regime_ticker,
            trade_ticker=args.trade_ticker,
            daily_period=args.daily_period,
            intraday_period=args.intraday_period,
            intraday_interval=args.interval,
            sma_window=args.sma,
            aqr_lookback=args.aqr_lookback,
            aqr_skip=args.aqr_skip,
            z_window=args.z_window,
            z_entry=args.z_entry,
            z_exit=args.z_exit,
            regime_shift_sessions=args.regime_shift_days,
            hedge_ticker=hedge,
            hedge_window=args.hedge_window,
            min_hedge_obs=args.min_hedge_obs,
        )

        print(
            f"📌 Intraday bars: {len(intra):,} | mode: {'pair spread' if hedge else 'single close'}"
        )
        _hr = 252.0 * 6.0
        summarize("Standalone MR", intra["standalone_ret"], bars_per_year=_hr)
        summarize("Filtered", intra["filtered_ret"], bars_per_year=_hr)

        ht = f" vs {hedge}" if hedge else ""
        title = f"{args.trade_ticker}{ht} {args.interval} MR vs momentum({args.regime_ticker})-filtered"
        try:
            plot_curves(intra, title=title, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "swing":
        # Daily swing mean-reversion (CRSI pullback)
        print("🧭 Loading daily data …")
        daily_in = DataLoader().fetch_daily(args.trade_ticker, period=args.daily_period)
        if daily_in.empty:
            raise RuntimeError(f"No daily data for {args.trade_ticker}")

        engine = SwingEngine(
            trend_sma=args.trend_sma,
            rsi_window=args.rsi_window,
            rsi_threshold=args.rsi_threshold,
            exit_sma=args.exit_sma,
        )
        daily = engine.transform(daily_in)

        daily["ret"] = daily["close"].astype(np.float64).pct_change()
        swing_ret = vectorized_strategy_returns(daily, "micro_position", ret_col="ret")

        # For compatibility with existing plotting code, map the swing return series.
        daily["standalone_ret"] = swing_ret
        daily["filtered_ret"] = swing_ret

        summarize("Swing CRSI Pullback", swing_ret)

        title = (
            f"Swing CRSI Pullback ({args.trade_ticker}) | "
            f"trend_sma={args.trend_sma}, rsi_window={args.rsi_window}, "
            f"rsi_thresh={args.rsi_threshold:g}, exit_sma={args.exit_sma}"
        )
        try:
            plot_curves(daily, title=title, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "swing-sector":
        # Tactical All Weather (baseline sleeves, move sleeves to cash when regime is bearish).
        from RenTech.strategy_stack.portfolio_risk_manager import BASE_WEIGHTS, PortfolioManager, plot_portfolio

        baseline_tickers = list(BASE_WEIGHTS.keys())
        print(f"🧭 Tactical All Weather baseline tickers: {baseline_tickers}")

        data_dict: dict[str, pd.DataFrame] = {}
        for t in baseline_tickers:
            daily_in = DataLoader().fetch_daily(t, period=args.daily_period)
            if daily_in.empty or "close" not in daily_in.columns:
                raise RuntimeError(f"No daily data for {t}")

            close = daily_in["close"].astype(np.float64)
            df = daily_in.copy()
            df["ret"] = close.pct_change()
            df["sma_200"] = close.rolling(window=200, min_periods=200).mean()
            # AQR 12-minus-1 momentum: close.shift(21) / close.shift(252) - 1
            df["aqr_mom"] = close.shift(21) / close.shift(252) - 1.0
            data_dict[t] = df

        pm = PortfolioManager()
        portfolio_df = pm.build_portfolio(data_dict, cash_annual_yield=0.04)

        summarize("Tactical All Weather", portfolio_df["portfolio_bar_ret"])
        static_bar_ret = (1.0 + portfolio_df["static_all_weather_cumulative_ret"]).pct_change().fillna(0.0)
        spy_bar_ret = (1.0 + portfolio_df["spy_cumulative_ret"]).pct_change().fillna(0.0)
        summarize("Static All Weather (baseline)", static_bar_ret)
        summarize("SPY buy & hold", spy_bar_ret)

        _print_monthly_returns_by_year(portfolio_df["portfolio_bar_ret"], "Tactical All Weather (total strategy)")

        title = "Tactical All Weather vs Static All Weather + SPY"
        try:
            plot_portfolio(portfolio_df, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "ls-equity":
        # Cross-sectional L/S momentum only (same engine as ensemble alpha sleeve).
        from RenTech.strategy_stack.multi_strategy_manager import (
            CrossSectionalMomentum,
            plot_ls_standalone,
            print_cross_sectional_ls_diagnostics,
        )

        print("🚀 Running equity L/S momentum (standalone) …")
        equity_dict = _load_aqr_equity_dict(
            args.daily_period,
            universe=args.ls_universe,
            max_tickers=args.ls_max_tickers,
        )

        if not equity_dict:
            raise RuntimeError("No equity universe data loaded; cannot run ls-equity")

        spy_daily = DataLoader().fetch_daily("SPY", period=args.daily_period)
        if spy_daily.empty:
            raise RuntimeError("No daily data for SPY (required for L/S regime throttle)")
        spy_df = _compute_daily_backtest_features(spy_daily)

        sector_map = (
            _wiki_sector_map_for_tickers(set(equity_dict.keys())) if args.ls_sector_neutral else None
        )
        if args.ls_sector_neutral:
            print("  L/S: sector-balanced seat allocation (Wikipedia GICS)")

        ls_engine = CrossSectionalMomentum()
        ls_diag: dict = {}
        ls_daily = ls_engine.generate_ls_returns(
            equity_dict,
            top_n=10,
            spy_df=spy_df,
            cash_annual_yield=0.04,
            sector_map=sector_map,
            diagnostics=ls_diag,
        )
        print_cross_sectional_ls_diagnostics(ls_diag)
        r = ls_daily.fillna(0.0).astype(np.float64)
        final_cum = float((1.0 + r).prod() - 1.0)
        sd = float(r.std(ddof=1)) if len(r) > 1 else 0.0
        mean = float(r.mean())
        sharpe = mean / sd * np.sqrt(252.0) if sd > 1e-12 else float("nan")
        print(f"  L/S equity (standalone): final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}")
        _print_monthly_returns_by_year(ls_daily, "L/S equity (standalone, total strategy)")
        try:
            plot_ls_standalone(ls_daily, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "ls-equity-dip":
        from RenTech.strategy_stack.multi_strategy_manager import BuyTheDipSleeve, plot_ls_standalone

        print("📉 Buy-the-dip sleeve (RSI dip + SMA uptrend, fixed hold, standalone) …")
        equity_dict = _load_aqr_equity_dict(
            args.daily_period,
            universe=args.ls_universe,
            max_tickers=args.ls_max_tickers,
        )
        if not equity_dict:
            raise RuntimeError("No equity universe data loaded; cannot run ls-equity-dip")

        spy_daily = DataLoader().fetch_daily("SPY", period=args.daily_period)
        if spy_daily.empty:
            raise RuntimeError("No daily data for SPY (required for regime throttle)")
        spy_df = _compute_daily_backtest_features(spy_daily)

        dip_eng = BuyTheDipSleeve(
            rsi_period=int(args.ensemble_dip_rsi_period),
            rsi_max=float(args.ensemble_dip_rsi_max),
            atr_period=int(args.ensemble_dip_atr_period),
            hold_trading_days=int(args.ensemble_dip_hold_days),
            vol_window=int(args.ensemble_dip_vol_window),
            sma_trend_window=int(args.ensemble_dip_trend_sma),
            weighting=str(args.ensemble_dip_weighting),
            rank_by=str(args.ensemble_dip_rank_by),
            dip_in_uptrend=not bool(args.ensemble_dip_no_uptrend_filter),
            only_when_spy_bull=bool(args.ensemble_dip_spy_bull_only),
            signal_mode=str(args.ensemble_dip_signal_mode),
            pct_drop_min=float(args.ensemble_dip_pct_drop_min),
        )
        dip_daily = dip_eng.generate_returns(
            equity_dict,
            top_n=int(args.ensemble_dip_top_n),
            spy_df=spy_df,
            cash_annual_yield=0.04,
        )
        r = dip_daily.fillna(0.0).astype(np.float64)
        final_cum = float((1.0 + r).prod() - 1.0)
        sd = float(r.std(ddof=1)) if len(r) > 1 else 0.0
        mean = float(r.mean())
        sharpe = mean / sd * np.sqrt(252.0) if sd > 1e-12 else float("nan")
        print(f"  Buy-the-dip (standalone): final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}")
        _print_monthly_returns_by_year(dip_daily, "Buy-the-dip (standalone, total strategy)")
        try:
            plot_ls_standalone(dip_daily, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "hourly-swing-breakout":
        from RenTech.strategy_stack.multi_strategy_manager import plot_ls_standalone
        from RenTech.strategy_stack.swing_breakout_hourly import (
            SwingBreakoutHourlyConfig,
            generate_portfolio_returns,
        )

        if bool(args.hourly_swing_long_only) and bool(args.hourly_swing_short_only):
            raise ValueError("Use at most one of --hourly-swing-long-only / --hourly-swing-short-only")

        print("📈 Hourly swing breakout (ETF universe, research backtest) …")
        panels = _load_hourly_swing_universe(
            args.intraday_period,
            args.interval,
            universe=str(args.hourly_swing_universe),
            max_tickers=int(args.hourly_swing_max_tickers),
            tickers_csv=str(args.hourly_swing_tickers),
        )
        if len(panels) < 3:
            raise RuntimeError(
                f"Too few tickers with hourly data ({len(panels)}). "
                "Try --hourly-swing-max-tickers, --intraday-period 730d, or --hourly-swing-tickers AAPL,MSFT,..."
            )

        cfg = SwingBreakoutHourlyConfig(
            fractal_left=int(args.hourly_swing_fractal_left),
            fractal_right=int(args.hourly_swing_fractal_right),
            daily_atr_period=int(args.hourly_swing_daily_atr),
            min_atr_mult=float(args.hourly_swing_min_atr_mult),
            hold_bars=int(args.hourly_swing_hold_bars),
            long_side=not bool(args.hourly_swing_short_only),
            short_side=not bool(args.hourly_swing_long_only),
            close_confirms_breakout=bool(args.hourly_swing_close_confirm),
        )
        port, counts = generate_portfolio_returns(
            panels,
            cfg,
            blend=str(args.hourly_swing_blend),
            vol_window=int(args.hourly_swing_vol_window),
            winner_epsilon=float(args.hourly_swing_winner_epsilon),
            verbose=True,
        )
        r = port.fillna(0.0).astype(np.float64)
        # ~6.5 RTH hours/day × 252 (rough annualization for hourly equity bars)
        bars_py = 252.0 * 6.5
        summarize("Hourly swing breakout (equal-weight)", r, bars_per_year=bars_py)
        _print_monthly_returns_by_year(r, "Hourly swing breakout (compounded monthly)")
        try:
            plot_ls_standalone(r, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "ml-equity":
        # XGBoost walk-forward L/S on S&P 500 (see ml_momentum_engine.py).
        from RenTech.strategy_stack.ml_momentum_engine import MLMomentumEngine
        from RenTech.strategy_stack.multi_strategy_manager import plot_ls_standalone

        print("🤖 ML momentum (XGB walk-forward) — requires xgboost …")
        equity_dict, sector_map = _load_sp500_ml_panel(args.daily_period, args.ml_max_tickers)
        if len(equity_dict) < 50:
            raise RuntimeError(
                f"Too few tickers after OHLCV load ({len(equity_dict)}). Check data or increase --daily-period."
            )

        eng = MLMomentumEngine(
            data_period=args.daily_period,
            target_mode=args.ml_target,
            weighting=args.ml_weighting,
        )
        ls_daily = eng.generate_ls_returns(
            equity_dict,
            sector_map=sector_map,
            cash_annual_yield=0.04,
            data_period=args.daily_period,
            target_mode=args.ml_target,
            weighting=args.ml_weighting,
        )
        r = ls_daily.fillna(0.0).astype(np.float64)
        final_cum = float((1.0 + r).prod() - 1.0)
        sd = float(r.std(ddof=1)) if len(r) > 1 else 0.0
        mean = float(r.mean())
        sharpe = mean / sd * np.sqrt(252.0) if sd > 1e-12 else float("nan")
        print(f"  ML L/S equity (standalone): final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}")
        _print_monthly_returns_by_year(ls_daily, "ML L/S equity (total strategy)")
        try:
            plot_ls_standalone(ls_daily, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "sector-etfs":
        from RenTech.strategy_stack.multi_strategy_manager import SectorETFRotation, plot_sector_rotation_standalone

        print("📊 Sector ETF rotation (11 SPDR sectors, 12-1 momentum) …")
        etf_dict = _load_sector_etf_dict(args.daily_period)
        sec_eng = SectorETFRotation()
        sec_daily = sec_eng.generate_returns(
            etf_dict,
            top_k=args.ensemble_sector_top_k,
            cash_annual_yield=0.04,
        )
        r = sec_daily.fillna(0.0).astype(np.float64)
        final_cum = float((1.0 + r).prod() - 1.0)
        sd = float(r.std(ddof=1)) if len(r) > 1 else 0.0
        mean = float(r.mean())
        sharpe = mean / sd * np.sqrt(252.0) if sd > 1e-12 else float("nan")
        print(f"  Sector ETF sleeve (standalone): final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}")
        _print_monthly_returns_by_year(sec_daily, "Sector ETF rotation (total strategy)")
        try:
            plot_sector_rotation_standalone(sec_daily, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "defensive-mr":
        from RenTech.strategy_stack.multi_strategy_manager import DefensiveMeanReversionSleeve, plot_defensive_mr_standalone

        print("🛡 Defensive mean reversion (SPY return z-score → TLT tilt) …")
        dl = DataLoader()
        spy_d = dl.fetch_daily("SPY", period=args.daily_period)
        tlt_d = dl.fetch_daily("TLT", period=args.daily_period)
        if spy_d.empty or tlt_d.empty:
            raise RuntimeError("Need SPY and TLT daily data for defensive-mr")
        spy_df = _compute_daily_backtest_features(spy_d)
        tlt_df = _compute_daily_backtest_features(tlt_d)
        d_eng = DefensiveMeanReversionSleeve(
            z_window=int(args.defensive_z_window),
            entry_z=float(args.defensive_entry_z),
            exit_z=float(args.defensive_exit_z),
            tlt_weight_when_active=float(args.defensive_tlt_weight),
        )
        d_daily = d_eng.generate_returns(spy_df, tlt_df, cash_annual_yield=0.04)
        r = d_daily.fillna(0.0).astype(np.float64)
        final_cum = float((1.0 + r).prod() - 1.0)
        sd = float(r.std(ddof=1)) if len(r) > 1 else 0.0
        mean = float(r.mean())
        sharpe = mean / sd * np.sqrt(252.0) if sd > 1e-12 else float("nan")
        print(f"  Defensive MR (standalone): final_cum={final_cum:.4f}  approx_sharpe={sharpe:.3f}")
        _print_monthly_returns_by_year(d_daily, "Defensive MR sleeve (total strategy)")
        try:
            plot_defensive_mr_standalone(d_daily, save_path=args.save_plot or None)
        except ImportError:
            pass
        return

    if args.mode == "ensemble":
        # Tactical All Weather + Market-neutral Long/Short Equity Momentum (overlay).
        from RenTech.strategy_stack.multi_strategy_manager import (
            BuyTheDipSleeve,
            DefensiveMeanReversionSleeve,
            EnsembleManager,
            SectorETFRotation,
            plot_ensemble,
            print_cross_sectional_ls_diagnostics,
        )

        print("🚀 Running multi-strategy ensemble …")

        # Step A: Load macro baseline tickers.
        macro_tickers = ["SPY", "TLT", "IEF", "GLD", "DBC"]
        data_loader = DataLoader()
        macro_dict: dict[str, pd.DataFrame] = {}
        for t in macro_tickers:
            daily = data_loader.fetch_daily(t, period=args.daily_period)
            if daily.empty:
                raise RuntimeError(f"No daily data for macro ticker {t}")
            macro_dict[t] = _compute_daily_backtest_features(daily)

        use_sector_sleeve = bool(args.ensemble_sector_sleeve) and float(args.ensemble_sector_weight) > 0.0
        sector_ret: pd.Series | None = None
        if use_sector_sleeve:
            print("📊 Building sector ETF sleeve (SPDR top-k momentum) …")
            etf_dict = _load_sector_etf_dict(args.daily_period)
            sector_ret = SectorETFRotation().generate_returns(
                etf_dict,
                top_k=int(args.ensemble_sector_top_k),
                cash_annual_yield=0.04,
            )

        use_def_sleeve = bool(args.ensemble_defensive_mr) and float(args.ensemble_defensive_weight) > 0.0
        defensive_ret: pd.Series | None = None
        if use_def_sleeve:
            print("🛡 Building defensive MR sleeve (SPY return z → TLT) …")
            d_eng = DefensiveMeanReversionSleeve(
                z_window=int(args.defensive_z_window),
                entry_z=float(args.defensive_entry_z),
                exit_z=float(args.defensive_exit_z),
                tlt_weight_when_active=float(args.defensive_tlt_weight),
            )
            # macro_dict SPY/TLT populated above
            defensive_ret = d_eng.generate_returns(macro_dict["SPY"], macro_dict["TLT"], cash_annual_yield=0.04)

        w_sec = float(args.ensemble_sector_weight) if use_sector_sleeve else 0.0
        w_def = float(args.ensemble_defensive_weight) if use_def_sleeve else 0.0
        use_dip_sleeve = bool(args.ensemble_dip_sleeve) and float(args.ensemble_dip_weight) > 0.0
        w_ls_short = float(args.ensemble_dip_weight) if use_dip_sleeve else 0.0
        ens = EnsembleManager(weight_sector=w_sec, weight_defensive=w_def, weight_ls_short=w_ls_short)
        ls_series: pd.Series | None = None
        equity_dict: dict[str, pd.DataFrame] | None = None
        equity_ml: dict[str, pd.DataFrame] | None = None
        aq_sector_map: dict[str, str] | None = None

        if args.ensemble_ls_engine == "ml":
            from RenTech.strategy_stack.ml_momentum_engine import MLMomentumEngine

            equity_ml, sector_map_ml = _load_sp500_ml_panel(args.daily_period, args.ml_max_tickers)
            if len(equity_ml) < 50:
                raise RuntimeError(
                    f"Too few tickers for ML sleeve ({len(equity_ml)}). Try --ml-max-tickers 150 or longer --daily-period."
                )
            print("⚖️ Building ML L/S sleeve (XGB walk-forward) …")
            eng = MLMomentumEngine(
                data_period=args.daily_period,
                target_mode=args.ml_target,
                weighting=args.ml_weighting,
            )
            ls_series = eng.generate_ls_returns(
                equity_ml,
                sector_map=sector_map_ml,
                cash_annual_yield=0.04,
                data_period=args.daily_period,
                target_mode=args.ml_target,
                weighting=args.ml_weighting,
            )
        else:
            # Step B: Load equity universe for AQR L/S.
            equity_dict = _load_aqr_equity_dict(
                args.daily_period,
                universe=args.ls_universe,
                max_tickers=args.ls_max_tickers,
            )

            if not equity_dict:
                raise RuntimeError("No equity universe data loaded; cannot build ensemble")
            if args.ls_sector_neutral:
                aq_sector_map = _wiki_sector_map_for_tickers(set(equity_dict.keys()))
                print("  L/S: sector-balanced seat allocation (Wikipedia GICS)")

        ls_short_ret: pd.Series | None = None
        if use_dip_sleeve:
            print("📉 Building buy-the-dip sleeve (RSI dip + SMA uptrend, fixed hold) …")
            dip_eng = BuyTheDipSleeve(
                rsi_period=int(args.ensemble_dip_rsi_period),
                rsi_max=float(args.ensemble_dip_rsi_max),
                atr_period=int(args.ensemble_dip_atr_period),
                hold_trading_days=int(args.ensemble_dip_hold_days),
                vol_window=int(args.ensemble_dip_vol_window),
                sma_trend_window=int(args.ensemble_dip_trend_sma),
                weighting=str(args.ensemble_dip_weighting),
                rank_by=str(args.ensemble_dip_rank_by),
                dip_in_uptrend=not bool(args.ensemble_dip_no_uptrend_filter),
                only_when_spy_bull=bool(args.ensemble_dip_spy_bull_only),
            )
            spy_m = macro_dict["SPY"]
            if equity_ml is not None:
                eq_dip = {t: _ensure_ml_panel_for_dip(df) for t, df in equity_ml.items()}
            elif equity_dict is not None:
                eq_dip = equity_dict
            else:
                raise RuntimeError("buy-the-dip sleeve requires a loaded equity universe")
            ls_short_ret = dip_eng.generate_returns(
                eq_dip,
                top_n=int(args.ensemble_dip_top_n),
                spy_df=spy_m,
                cash_annual_yield=0.04,
            )

        # Step D: Execute ensemble.
        blend_bits = []
        if use_sector_sleeve:
            blend_bits.append("sector ETFs")
        if use_def_sleeve:
            blend_bits.append("defensive MR")
        if use_dip_sleeve:
            blend_bits.append("buy-the-dip")
        blend_msg = " + ".join(blend_bits)
        print("⚖️ Blending macro + L/S" + (f" + {blend_msg}" if blend_msg else "") + " …")
        ls_diagnostics: dict = {}
        if ls_series is not None:
            ensemble_df = ens.build_ensemble(
                macro_dict,
                equity_dict=None,
                ls_daily_ret=ls_series,
                sector_daily_ret=sector_ret,
                defensive_daily_ret=defensive_ret,
                ls_short_daily_ret=ls_short_ret,
            )
            print(
                "  L/S sleeve (ML): AQR-style rebalance/trade diagnostics are not produced for this engine."
            )
        else:
            ensemble_df = ens.build_ensemble(
                macro_dict,
                equity_dict,
                sector_map=aq_sector_map,
                sector_daily_ret=sector_ret,
                defensive_daily_ret=defensive_ret,
                ls_short_daily_ret=ls_short_ret,
                ls_diagnostics=ls_diagnostics,
            )
            print_cross_sectional_ls_diagnostics(ls_diagnostics)

        # Step E: Metrics & plotting.
        def _print_curve_stats(name: str, cum_col: str) -> None:
            if cum_col not in ensemble_df.columns:
                raise KeyError(f"Missing {cum_col} in ensemble output")
            cum = ensemble_df[cum_col].astype(np.float64)
            _print_cumulative_curve_diagnostics(name, cum)

        _print_curve_stats("Macro book", "macro_cumulative_ret")
        _print_curve_stats("L/S book", "ls_cumulative_ret")
        if use_sector_sleeve:
            _print_curve_stats("Sector ETF sleeve (unweighted leg)", "sector_cumulative_ret")
        if use_def_sleeve:
            _print_curve_stats("Defensive MR sleeve (unweighted leg)", "defensive_cumulative_ret")
        if use_dip_sleeve:
            _print_curve_stats("Buy-the-dip sleeve (unweighted leg)", "ls_short_cumulative_ret")
        _print_curve_stats("Ensemble", "ensemble_cumulative_ret")

        # SPY buy-and-hold on the same dates as the ensemble (benchmark for investor materials / PDF).
        spy_bnh_cum: pd.Series | None = None
        spy_panel = macro_dict.get("SPY")
        if spy_panel is not None and not spy_panel.empty and "ret" in spy_panel.columns:
            idx = ensemble_df.index
            spy_r = spy_panel["ret"].reindex(idx).astype(np.float64).fillna(0.0)
            spy_bnh_cum = (1.0 + spy_r).cumprod() - 1.0
            print("\n  --- Benchmark (same calendar as ensemble): SPY buy & hold ---")
            _print_cumulative_curve_diagnostics("SPY (S&P 500 ETF)", spy_bnh_cum)
            sp_pct = float(spy_bnh_cum.iloc[-1]) * 100.0
            ens_pct = float(ensemble_df["ensemble_cumulative_ret"].astype(np.float64).iloc[-1]) * 100.0
            print(
                f"  investor_metrics.json hints — benchmark.total_return_pct ≈ {sp_pct:.2f}  "
                f"(percent over window); rows[0] ensemble total_return_pct ≈ {ens_pct:.2f}; "
                f"excess vs SPY ≈ {ens_pct - sp_pct:.2f} pp"
            )

        export_json = str(getattr(args, "export_investor_metrics_json", "") or "").strip()
        if export_json:
            _export_ensemble_investor_metrics_json(
                export_json,
                ensemble_df=ensemble_df,
                use_sector_sleeve=use_sector_sleeve,
                use_def_sleeve=use_def_sleeve,
                use_dip_sleeve=use_dip_sleeve,
                spy_bnh_cum=spy_bnh_cum,
                args=args,
                ensemble_manager=ens,
                ls_diagnostics=ls_diagnostics,
            )

        ens_cum = ensemble_df["ensemble_cumulative_ret"].astype(np.float64)
        ens_daily = (1.0 + ens_cum).pct_change().fillna(0.0).astype(np.float64)
        _print_monthly_returns_by_year(ens_daily, "Ensemble (total strategy)")

        plot_ensemble(ensemble_df)
        return

    if args.mode == "scan":
        print("🔍 Scanning Universe for Tradable Cointegrated Pairs …")
        import universe_scanner as us  # type: ignore[import-not-found]

        # Interpret our `daily-period` (e.g. "5y") as universe_scanner's cointegration lookback.
        dp = str(args.daily_period).strip().lower()
        years = 5.0
        if dp.endswith("y"):
            try:
                years = float(dp[:-1])
            except Exception:
                years = 5.0

        # universe_scanner.main() uses its own argparse, so run it with a temporary argv.
        saved_argv = sys.argv[:]
        try:
            sys.argv = [
                saved_argv[0],
                "--universe",
                "sp100",
                "--timeframe",
                "1d",
                "--lookback-years",
                str(years),
                "--coint-p",
                "0.05",
            ]
            us.main()
        finally:
            sys.argv = saved_argv
        return

    if args.mode == "pipeline":
        print("🚀 Starting End-to-End Pipeline …")
        import universe_scanner as us  # type: ignore[import-not-found]
        import RenTech.strategy_stack.optimizer as opt
        from RenTech.strategy_stack.portfolio_risk_manager import PortfolioManager, plot_portfolio

        pair_y_in = str(args.pair_y).strip()
        pair_x_in = str(args.pair_x).strip()
        if bool(pair_y_in) ^ bool(pair_x_in):
            raise ValueError("Pipeline: pass both --pair-y and --pair-x, or neither.")

        import tqdm  # type: ignore[import-untyped]

        # Resolve candidate pairs.
        if pair_y_in and pair_x_in:
            candidates: list[tuple[str, str]] = [(pair_y_in, pair_x_in)]
            print(f"✅ Using fixed pairs (skip universe scan): {pair_y_in} (Y) vs {pair_x_in} (X)")
        else:
            print(f"🔍 Step 1: Scanning Universe (S&P 100) … (looking for top {args.portfolio_top_k} pairs)")
            dp = str(args.daily_period).strip().lower()
            years = 5.0
            if dp.endswith("y"):
                try:
                    years = float(dp[:-1])
                except Exception:
                    years = 5.0

            tickers = us.get_sp100_tickers(universe="sp100")
            data_dict = us.download_and_cache_data(
                tickers,
                timeframe="1d",
                lookback_years=years,
            )
            pairs, coint_label = find_cointegrated_pairs_with_fallback(us, data_dict)
            pairs = pairs.sort_values("Adjusted_P_Value", ascending=True)
            if pairs.empty:
                raise RuntimeError(
                    "Universe scan returned no tradable pairs (even with relaxed coint + "
                    "tradability thresholds). Re-run with an explicit pair, e.g. "
                    "`--pair-y QQQ --pair-x SPY`."
                )
            if coint_label:
                print(f"   (coint scan: {coint_label})")

            k = max(1, int(args.portfolio_top_k))
            pairs_top = pairs.head(k)
            candidates = [(str(r["Ticker_A"]), str(r["Ticker_B"])) for _, r in pairs_top.iterrows()]

        # Optimize independently, then slice each pair's OOS filtered_ret for aggregation.
        pair_results: dict[str, pd.DataFrame] = {}
        skipped: list[str] = []

        for pair_y, pair_x in tqdm.tqdm(candidates, desc="Pairs", unit="pair"):
            pair_name = f"{pair_y}_{pair_x}"
            try:
                print(f"\n=== Pair {pair_name} ===")
                print("⚙️ Loading bundle …")
                bundle = opt.load_intraday_bundle(
                    regime_ticker=args.regime_ticker,
                    trade_ticker=pair_y,
                    hedge_ticker=pair_x,
                    daily_period=args.daily_period,
                    intraday_period=args.intraday_period,
                    intraday_interval=args.interval,
                )

                train_y, test_y = opt.time_ordered_train_test_split(
                    bundle.intra_y,
                    train_frac=0.7,
                )

                # Grid-search on the training slice (in optimizer).
                is_res, _ = opt.run_optimization(
                    bundle,
                    train_frac=0.7,
                    top_k=3,
                    heatmap_path=None,
                    plot_heatmap=False,
                    aqr_lookback=args.aqr_lookback,
                    aqr_skip=args.aqr_skip,
                    regime_shift_sessions=args.regime_shift_days,
                )
                if is_res.empty:
                    raise RuntimeError("Optimizer returned empty grid result")
                best = is_res.sort_values("is_sharpe", ascending=False).iloc[0]

                z_entry_opt = float(best["z_entry"])
                z_exit_opt = float(best["z_exit"])
                z_window_opt = int(best["statarb_window"])
                mom_window_opt = int(best["mom_window"])
                hedge_window_opt = z_window_opt
                min_hedge_obs_opt = opt.min_hedge_obs_for_window(z_window_opt)

                print(
                    "✅ Optimal params (train): "
                    f"z_window={z_window_opt}, z_entry={z_entry_opt:g}, z_exit={z_exit_opt:g}, "
                    f"mom_window={mom_window_opt}, hedge_window={hedge_window_opt}"
                )

                # OOS backtest on the optimized params.
                intra_full = run_pipeline(
                    regime_ticker=args.regime_ticker,
                    trade_ticker=pair_y,
                    daily_period=args.daily_period,
                    intraday_period=args.intraday_period,
                    intraday_interval=args.interval,
                    sma_window=mom_window_opt,
                    aqr_lookback=args.aqr_lookback,
                    aqr_skip=args.aqr_skip,
                    z_window=z_window_opt,
                    z_entry=z_entry_opt,
                    z_exit=z_exit_opt,
                    regime_shift_sessions=args.regime_shift_days,
                    hedge_ticker=pair_x,
                    hedge_window=hedge_window_opt,
                    min_hedge_obs=min_hedge_obs_opt,
                )

                test_start = test_y.index.min()
                intra_oos = intra_full.loc[intra_full.index >= test_start].copy()
                if intra_oos.empty or "filtered_ret" not in intra_oos.columns:
                    raise RuntimeError("OOS slice empty or missing filtered_ret")

                summarize(f"OOS Filtered {pair_name}", intra_oos["filtered_ret"], bars_per_year=252.0 * 6.0)
                pair_results[pair_name] = intra_oos[["filtered_ret"]].copy()

            except Exception as e:
                skipped.append(pair_name)
                print(f"⚠️ Skipping {pair_name} due to error: {e}")
                continue

        if not pair_results:
            raise RuntimeError("No pair results available to build portfolio.")

        print("\n🚀 Building portfolio (inverse-vol risk parity + kill switch) …")
        pm = PortfolioManager()
        portfolio_df = pm.build_portfolio(
            pair_results,
            vol_window=int(args.portfolio_vol_window),
            max_dd_threshold=float(args.portfolio_max_dd),
        )

        _hr = 252.0 * 6.0
        summarize("Portfolio (kill switch)", portfolio_df["portfolio_bar_ret"], bars_per_year=_hr)
        summarize("Equal Weight (no kill switch)", portfolio_df["equal_weight_bar_ret"], bars_per_year=_hr)

        print("📈 Plotting portfolio vs equal weight benchmark …")
        plot_portfolio(
            portfolio_df,
            save_path=args.portfolio_save_plot or None,
        )
        return

    raise RuntimeError(f"Unknown --mode: {args.mode}")


if __name__ == "__main__":
    main()
