"""
4-regime VRP engine with **SPY > SMA(200)**; **R1/R3/R4** use **2%** risk per trade; **R2** opens **R2a** (2%
  diagonal, same rules as before) and **R2b** (1% put spread) **at once**, each with its own TP/SL/time stop:

- **SPY > SMA(200):** R1–R4 by VIX (weekly long strangle R1; **R2 = R2a put diagonal + R2b put spread** as two
  concurrent positions with separate exits; put credit spread R3; wider PCS R4).
- **Optional VVIX / VIX-momentum scaling:** ``load_spy_vix_from_yfinance`` pulls ``^VVIX``; when
  ``vol_risk_scaling=True`` (default), **global** target risk is cut if VVIX is high vs its 20d median;
  **R2** gets an additional cut if 5d VIX change or VVIX vs median signals stress.
- **R2 crossover filters:** when ``r2_crossover_filters=True`` (default), **only** in the R2 VIX band
  ``12 ≤ VIX ≤ 20``, entries require **SPY > SMA(50)** and block when **VIX MA(20) / VIX max(50)**
  is **too high** (VIX smoothed level too close to its 50-trading-day rolling max). (R1 / R3 / R4
  never use these checks.) **Global VVIX risk scaling** does **not** apply to **R1** (``VIX < 12``).
- **SPY ≤ SMA(200):** no new options; open positions are still marked and closed on rules.
- **Portfolio drawdown scaling (optional):** when enabled, **marked equity** (cash + open position MTM) vs a
  running **peak** defines drawdown. If DD ≥ ``dd_scale_enter``, new entries use ``dd_scale_mult`` on all
  risk sleeves until DD ≤ ``dd_scale_exit`` (hysteresis). This is **not** a delta hedge; a true hedge would be a
  separate overlay (e.g. long VIX calls, SPY puts) and would need its own legs and PnL in the engine.

Loaders: :class:`~RenTech.core.synthetic_data_loader.SyntheticLoader`,
:class:`~RenTech.core.options_data_loader.IVolatilityLoader` (Parquet), or
:class:`~RenTech.core.theta_chunks_loader.ThetaChunksLoader` (Theta 15:45 chunks).

**Macro overlay (optional):** when ``macro_overlay_enabled=True``, each session adds a small
**cash PnL** tied to Tier A index/ETF/synthetic **daily return** columns (Yahoo-aligned), keyed by
the same VIX band as R1–R4 (see ``tier_a_series.DEFAULT_MACRO_OVERLAY_BY_BAND``). This is a
**parallel sleeve** to the option legs, not a replacement for Theta fills.

**Overlap / DCA mode (optional):** when ``VRPBacktester(..., overlap_portfolio=True)``, new entries are
allowed **while positions are still open**. Each session adds ``overlap_slice_contracts`` (default 1)
per sleeve **if** today's VIX regime matches and aggregate **sizing commitment** for that sleeve
(open positions of that regime) stays within the same ``target_risk_usd`` cap as the legacy
single-wave sizing. Each ``ManagedPosition`` still has its own TP / SL / time stop (independent
lifecycle). R2 still opens **R2a + R2b together** when both legs build; otherwise neither adds.

**Execution:** :data:`SLIPPAGE_FACTOR` scales how much of the half-spread is paid vs mid
(0 = mid-only, 1 = full bid/ask cross). Applies to opens, closes, and MTM marks when bid/ask exist.

Default window: **2004-01-01 → 2024-01-01** (yfinance SPY/VIX panel).
"""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

import math
import statistics
import warnings
from dataclasses import dataclass
from typing import Any, Literal, Union, cast

import pandas as pd
from tqdm import tqdm

from RenTech.core.options_backtest import find_contract_in_chain
from RenTech.core.options_data_loader import IVolatilityLoader, OptionChain, OptionContract
from RenTech.core.synthetic_data_loader import SyntheticLoader, SyntheticOptionChain
from RenTech.core.theta_chunks_loader import ThetaChunksLoader
from RenTech.strategy_stack.vrp_strategy_config import (
    DEFAULT_STRATEGY_CONFIG_PATH as DEFAULT_SLEEVE_RISK_JSON_PATH,
    apply_strategy_params_to_vrp_backtester_module,
    load_strategy_config_file,
)

LegAction = Literal["buy", "sell"]
Regime = Literal[
    "pmcc",
    "diagonal",
    "r2_spread",
    "naked",
    "credit_spread",
]

# Isolated-sleeve benchmarks: same Theta/iVol engine, only one structure (or R2 pair) active.
PortfolioMode = Literal[
    "full",
    "r1_strangle",
    "r2_diagonal",
    "r2_spread",
    "r2_pair",
    "r3_put_spread",
    "r4_credit_spread",
]
_VALID_PORTFOLIO_MODES: frozenset[str] = frozenset(
    (
        "full",
        "r1_strangle",
        "r2_diagonal",
        "r2_spread",
        "r2_pair",
        "r3_put_spread",
        "r4_credit_spread",
    )
)

CONTRACT_MULTIPLIER = 100.0
# Fractional spread execution: 0.0 = mid fills, 1.0 = pay full half-spread from mid (bid/ask cross).
SLIPPAGE_FACTOR = 0.20
# Execution uses this at runtime; ``VRPBacktester`` sets it per ``run_backtest`` from ``self.slippage_factor``.
_active_slippage: float = SLIPPAGE_FACTOR


def _slippage() -> float:
    return _active_slippage


def _set_active_slippage(sf: float) -> None:
    global _active_slippage
    _active_slippage = float(max(0.0, min(1.0, sf)))

# --- Regime 1: Complacency (VIX < 12) — weekly long strangle (same expiry)
# Buy call nearer ATM (higher Δ); buy put further OTM (lower |Δ|). Both legs ~weekly DTE.
R1_STRANGLE_DTE = 7
R1_CALL_DELTA = 0.45  # closer to spot
R1_PUT_DELTA = -0.18  # further OTM than the call side
R1_TIME_STOP_DAYS = 7
R1_TP_FRAC = 0.50  # +50% of debit
R1_SL_FRAC = -0.50  # -50% of debit

# --- Regime 2: Normal (12 <= VIX <= 20) — **R2a** + **R2b** opened together, managed separately
# R2a — put diagonal (unchanged): same rules as standalone diagonal; sized like other regimes
# (``RISK_FRACTION_PER_TRADE`` × vol scaling).
# R2b — put credit spread: collects premium; sized from ``R2_PUT_SPREAD_RISK_FRACTION`` of capital.
R2_DIAG_LEG_SPECS: list[tuple[int, float, LegAction]] = [
    (21, -0.30, "sell"),
    (45, -0.15, "buy"),
]
R2_RISK_PER_CONTRACT = 150.0  # R2a diagonal sizing denominator (per 1-lot)
R2_TP_PER_CONTRACT = 50.0
R2_SL_PER_CONTRACT = -150.0
R2_TIME_STOP_DAYS = 14
# R2b short leg: target DTE / delta. Long leg: strike = short_strike − round(spot × R2_SPREAD_WIDTH_FRAC_OF_SPOT)
# (minimum width 1 strike step); same expiry as short. Replaces fixed −9Δ long targeting.
R2_SPREAD_TARGET_DTE = 30
R2_SPREAD_SHORT_DELTA = -0.22
R2_SPREAD_WIDTH_FRAC_OF_SPOT = 0.01
R2_CREDIT_LEG_SPECS: list[tuple[int, float, LegAction]] = [
    (30, -0.22, "sell"),
    (30, -0.09, "buy"),
]
R2_SPREAD_TP_FRAC = 0.50
R2_SPREAD_TIME_STOP_DAYS = 21
R2_PUT_SPREAD_RISK_FRACTION = 0.01  # R2b: fraction of capital for PCS max-loss sizing
# R2 entry filters: require SPY > SMA(50); block when VIX MA(20) / rolling max(VIX,50) >= threshold.
R2_SPY_MA_SHORT_DAYS = 50
R2_VIX_SLOW_MA_DAYS = 20  # VIX MA for numerator
R2_VIX_ROLL_MAX_DAYS = 50  # rolling max of VIX close for denominator
# Block R2 entries when this ratio is >= threshold (VIX “pinned” near its 50d high).
R2_VIX_MA_TO_MAX_BLOCK_ABOVE = 0.95

# --- Regime 3: Elevated fear (20 < VIX <= 30) — put credit spread (defined max loss)
# Short nearer-the-money put + long OTM put same expiry; wing looser than R4’s −5Δ long leg.
R3_CREDIT_LEG_SPECS: list[tuple[int, float, LegAction]] = [
    (45, -0.15, "sell"),
    (45, -0.07, "buy"),
]
R3_TP_FRAC = 0.50
R3_TIME_STOP_DAYS = 24

# --- Regime 4: Extreme panic (VIX > 30) — wide put credit spread
R4_CREDIT_LEG_SPECS: list[tuple[int, float, LegAction]] = [
    (45, -0.15, "sell"),
    (45, -0.05, "buy"),
]
R4_TP_FRAC = 0.50
R4_TIME_STOP_DAYS = 24

# --- VIX bands
VIX_R1_MAX = 12.0  # R1 weekly strangle: VIX < 12
VIX_R2_MAX = 20.0  # R2a + R2b: 12 <= VIX <= 20
VIX_R3_MAX = 30.0  # R3 put spread: 20 < VIX <= 30
# credit spread: VIX > 30

DEFAULT_YF_START = "2004-01-01"
DEFAULT_YF_END = "2024-01-01"
DEFAULT_STARTING_CAPITAL = 100_000.0
RISK_FRACTION_PER_TRADE = 0.02
# Per-sleeve defaults (keeps legacy behavior: core sleeves at 2%, R2b at 1%).
DEFAULT_SLEEVE_RISK_FRACTIONS: dict[Regime, float] = {
    "pmcc": RISK_FRACTION_PER_TRADE,
    "diagonal": RISK_FRACTION_PER_TRADE,
    "r2_spread": R2_PUT_SPREAD_RISK_FRACTION,
    "naked": RISK_FRACTION_PER_TRADE,
    "credit_spread": RISK_FRACTION_PER_TRADE,
}
# Avoid qty blow-ups when strike width or net credit makes economic max loss ~0.
SIZING_MIN_MAX_RISK_USD = 25.0

# --- Portfolio drawdown scaling (optional; see ``VRPBacktester(dd_risk_scaling=True)``)
# Marked equity = cash + open-option MTM. Peak = running max of marked equity.
DD_SCALE_ENTER_DEFAULT = 0.15  # enter reduced-risk mode when (peak - eq) / peak >= 15%
DD_SCALE_EXIT_DEFAULT = 0.10  # exit reduced mode when DD <= 10% (must be < ENTER)
DD_SCALE_MULT_DEFAULT = 0.50  # multiply all new-entry risk by this while in reduced mode

# --- VVIX / VIX momentum risk scaling (multiplies target_risk for new entries)
# Global: when VVIX is high vs its rolling median, cut all regimes.
# R2 extra: when 5d VIX spike and/or VVIX elevated vs median, cut R2 further (stacked on global).
VVIX_MEDIAN_WINDOW = 20
VVIX_GLOBAL_THRESHOLD_MULT = 1.15
VVIX_GLOBAL_RISK_SCALE = 0.65
VIX_R2_CHANGE_DAYS = 5
VIX_R2_CHANGE_THRESH = 2.0
R2_VVIX_STRESS_MULT = 1.10
R2_STRESS_RISK_SCALE = 0.55

# IVolatility Parquet default (combine_csvs_to_parquet); optional for __main__ validation runs.
DEFAULT_IV_PARQUET_PATH = _REPO_ROOT / "SPY-Option-Data" / "spy_options_eod_combined.parquet"

ContractKey = tuple[pd.Timestamp, float, str]


def _norm_day(ts: pd.Timestamp | object) -> pd.Timestamp:
    return pd.Timestamp(ts).normalize()


def contract_key(leg: OptionContract) -> ContractKey:
    return (_norm_day(leg.expiration), round(float(leg.strike), 4), str(leg.option_type))


def find_weekly_long_strangle_same_expiry(
    chain: OptionChain,
    target_dte: int,
    call_target_delta: float,
    put_target_delta: float,
) -> tuple[OptionContract, OptionContract] | None:
    """
    Same expiry (~``target_dte`` calendar days): long call near target call delta,
    long put near target put delta (typically more OTM than the call).
    """
    if not chain.contracts:
        return None
    as_of = pd.Timestamp(chain.as_of).normalize()

    expiries: set[pd.Timestamp] = set()
    for c in chain.contracts:
        exp = pd.Timestamp(c.expiration).normalize()
        if int((exp - as_of).days) >= 1:
            expiries.add(exp)

    if not expiries:
        return None

    scored: list[tuple[float, pd.Timestamp]] = []
    for exp in expiries:
        dte = int((exp - as_of).days)
        has_c = any(
            pd.Timestamp(x.expiration).normalize() == exp and x.option_type == "C" for x in chain.contracts
        )
        has_p = any(
            pd.Timestamp(x.expiration).normalize() == exp and x.option_type == "P" for x in chain.contracts
        )
        if not (has_c and has_p):
            continue
        scored.append((abs(dte - int(target_dte)), exp))

    if not scored:
        return None

    scored.sort(key=lambda kv: kv[0])
    for _, exp in scored:
        bucket_c = [
            c
            for c in chain.contracts
            if pd.Timestamp(c.expiration).normalize() == exp
            and c.option_type == "C"
            and math.isfinite(float(c.delta))
        ]
        bucket_p = [
            c
            for c in chain.contracts
            if pd.Timestamp(c.expiration).normalize() == exp
            and c.option_type == "P"
            and math.isfinite(float(c.delta))
        ]
        if not bucket_c or not bucket_p:
            continue
        call_leg = min(bucket_c, key=lambda c: abs(float(c.delta) - float(call_target_delta)))
        put_leg = min(bucket_p, key=lambda c: abs(float(c.delta) - float(put_target_delta)))
        if float(put_leg.strike) >= float(call_leg.strike):
            continue
        return (call_leg, put_leg)

    return None


def _quote_ok(x: Any) -> bool:
    try:
        v = float(x)
    except (TypeError, ValueError):
        return False
    return math.isfinite(v) and v > 0.0


def _mid_ok(mid: Any) -> bool:
    try:
        x = float(mid)
    except (TypeError, ValueError):
        return False
    return math.isfinite(x) and x >= 0.0


def calculate_fill_price(
    bid: float,
    ask: float,
    action: LegAction,
    slippage_factor: float,
) -> float:
    """
    Institutional fractional-spread fill vs mid.

    ``mid = (bid + ask) / 2``, ``half_spread = (ask - bid) / 2``.

    * **buy:** ``mid + half_spread * slippage_factor``
    * **sell:** ``mid - half_spread * slippage_factor``

    Broken quotes (non-positive bid/ask, inverted, or non-finite) fall back to a safe mid-style
    print so execution prices stay non-negative.
    """
    sf = float(slippage_factor)
    sf = max(0.0, min(1.0, sf))

    try:
        b = float(bid)
        a = float(ask)
    except (TypeError, ValueError):
        return 0.0

    if not math.isfinite(b) or not math.isfinite(a):
        return max(0.0, b if math.isfinite(b) else (a if math.isfinite(a) else 0.0))

    b = max(0.0, b)
    a = max(0.0, a)

    if b <= 0.0 and a <= 0.0:
        return 0.0

    if b > 0.0 and a > 0.0:
        if a < b:
            b, a = a, b
        mid = 0.5 * (b + a)
        half_spread = 0.5 * (a - b)
        if action == "buy":
            px = mid + half_spread * sf
        else:
            px = mid - half_spread * sf
        return max(0.0, px)

    # One-sided: no spread width — use the valid side as both bid and ask (mid = that side).
    m = b if b > 0.0 else a
    return max(0.0, m)


def execution_price_per_share(
    c: OptionContract,
    action: LegAction,
    slippage_factor: float = SLIPPAGE_FACTOR,
) -> float | None:
    """
    Executable price for *open* or *close* using bid/ask when sane, else mid or single-side fallback.
    Used for Parquet EOD and Synthetic chains alike.
    """
    bid_f = float(c.bid) if _quote_ok(c.bid) else None
    ask_f = float(c.ask) if _quote_ok(c.ask) else None
    mid_f = float(c.mid) if _mid_ok(c.mid) else None

    if bid_f is not None and ask_f is not None and bid_f > 0.0 and ask_f > 0.0:
        return calculate_fill_price(bid_f, ask_f, action, slippage_factor)
    if mid_f is not None and math.isfinite(mid_f) and mid_f >= 0.0:
        return calculate_fill_price(mid_f, mid_f, action, slippage_factor)
    if bid_f is not None and bid_f > 0.0:
        return calculate_fill_price(bid_f, bid_f, action, slippage_factor)
    if ask_f is not None and ask_f > 0.0:
        return calculate_fill_price(ask_f, ask_f, action, slippage_factor)
    return None


def _exit_mark_mid_fallback(mid_raw: float | None, close_act: LegAction) -> float:
    """MTM when only a stored mid exists (ffill / last leg.mid); do not use stale entry bid/ask."""
    if mid_raw is None:
        return 0.0
    try:
        m = max(0.0, float(mid_raw))
    except (TypeError, ValueError):
        return 0.0
    if not math.isfinite(m):
        return 0.0
    return calculate_fill_price(m, m, close_act, _slippage())


def _open_buy_per_share(c: OptionContract) -> float | None:
    return execution_price_per_share(c, "buy", _slippage())


def _open_sell_per_share(c: OptionContract) -> float | None:
    return execution_price_per_share(c, "sell", _slippage())


def _close_long_per_share(c: OptionContract) -> float | None:
    return execution_price_per_share(c, "sell", _slippage())


def _close_short_per_share(c: OptionContract) -> float | None:
    return execution_price_per_share(c, "buy", _slippage())


def put_intrinsic_per_share(strike: float, spy_close: float) -> float:
    return max(0.0, float(strike) - float(spy_close))


def call_intrinsic_per_share(strike: float, spy_close: float) -> float:
    return max(0.0, float(spy_close) - float(strike))


def intrinsic_per_share_leg(leg: OptionContract, spy_close: float) -> float:
    k = float(leg.strike)
    ot = str(leg.option_type).upper()
    if ot == "C":
        return call_intrinsic_per_share(k, spy_close)
    return put_intrinsic_per_share(k, spy_close)


def normalize_spy_df(spy_df: pd.DataFrame) -> pd.DataFrame:
    need = ("close", "sma_200", "vix_close")
    if not all(c in spy_df.columns for c in need):
        raise ValueError(f"spy_df must contain columns {need}.")
    out = spy_df.copy()
    out.index = pd.to_datetime(out.index).normalize()
    out = out[~out.index.duplicated(keep="last")].sort_index()
    out[f"sma_{R2_SPY_MA_SHORT_DAYS}"] = out["close"].rolling(R2_SPY_MA_SHORT_DAYS, min_periods=1).mean()
    out[f"vix_sma_{R2_VIX_SLOW_MA_DAYS}"] = out["vix_close"].rolling(R2_VIX_SLOW_MA_DAYS, min_periods=1).mean()
    out["vix_max_50"] = out["vix_close"].rolling(R2_VIX_ROLL_MAX_DAYS, min_periods=1).max()
    if "vvix_close" in out.columns:
        out["vvix_close"] = pd.to_numeric(out["vvix_close"], errors="coerce")
        out["vvix_close"] = out["vvix_close"].ffill().bfill()
        out["vvix_med_20"] = out["vvix_close"].rolling(
            VVIX_MEDIAN_WINDOW, min_periods=max(5, VVIX_MEDIAN_WINDOW // 4)
        ).median()
        out["vix_chg_5d"] = out["vix_close"] - out["vix_close"].shift(VIX_R2_CHANGE_DAYS)
    if "vix9d_close" in out.columns:
        out["vix9d_close"] = pd.to_numeric(out["vix9d_close"], errors="coerce").ffill().bfill()
    return out


def _extract_close_series(raw: pd.DataFrame, label: str) -> pd.Series:
    if raw is None or raw.empty:
        raise ValueError(f"yfinance returned no rows for {label}")
    if isinstance(raw.columns, pd.MultiIndex):
        lv0 = list(raw.columns.get_level_values(0))
        if "Adj Close" in lv0:
            col = raw["Adj Close"]
        elif "Close" in lv0:
            col = raw["Close"]
        else:
            col = raw.iloc[:, 0]
        if isinstance(col, pd.DataFrame):
            col = col.iloc[:, 0]
    else:
        col = raw["Adj Close"] if "Adj Close" in raw.columns else raw["Close"]
    ser = col.squeeze()
    if isinstance(ser, pd.DataFrame):
        ser = ser.iloc[:, 0]
    ser = pd.to_numeric(ser, errors="coerce").astype(float)
    ser.index = pd.to_datetime(ser.index).normalize()
    return ser[~ser.index.duplicated(keep="last")].sort_index()


def load_spy_vix_from_yfinance(
    start: str = DEFAULT_YF_START,
    end: str = DEFAULT_YF_END,
) -> pd.DataFrame:
    try:
        import yfinance as yf
    except ImportError as e:
        raise ImportError("yfinance required: pip install yfinance") from e

    spy_raw = yf.download("SPY", start=start, end=end, progress=False, auto_adjust=False, threads=False)
    vix_raw = yf.download("^VIX", start=start, end=end, progress=False, auto_adjust=False, threads=False)
    vvix_raw = yf.download("^VVIX", start=start, end=end, progress=False, auto_adjust=False, threads=False)
    vix9d_raw = yf.download("^VIX9D", start=start, end=end, progress=False, auto_adjust=False, threads=False)

    spy_close = _extract_close_series(spy_raw, "SPY").rename("close")
    vix_close = _extract_close_series(vix_raw, "^VIX").rename("vix_close")
    try:
        vvix_close = _extract_close_series(vvix_raw, "^VVIX").rename("vvix_close")
    except ValueError:
        vvix_close = pd.Series(dtype=float, name="vvix_close")
    try:
        vix9d_close = _extract_close_series(vix9d_raw, "^VIX9D").rename("vix9d_close")
    except ValueError:
        vix9d_close = pd.Series(dtype=float, name="vix9d_close")

    parts = [spy_close, vix_close]
    if not vvix_close.empty:
        parts.append(vvix_close)
    if not vix9d_close.empty:
        parts.append(vix9d_close)
    df = pd.concat(parts, axis=1, join="outer").sort_index()
    df["vix_close"] = df["vix_close"].ffill().bfill()
    if "vvix_close" in df.columns:
        df["vvix_close"] = df["vvix_close"].ffill().bfill()
    if "vix9d_close" in df.columns:
        df["vix9d_close"] = df["vix9d_close"].ffill().bfill()
    df = df.dropna(subset=["close"])
    df["sma_200"] = df["close"].rolling(200, min_periods=1).mean()
    return df


def load_spy_from_yfinance(start: str = DEFAULT_YF_START, end: str = DEFAULT_YF_END) -> pd.DataFrame:
    return load_spy_vix_from_yfinance(start, end)


@dataclass
class TwoLegSpreadPosition:
    """Two legs with explicit buy/sell; MTM is mark-to-market P&amp;L for **one** 1× structure."""

    entry_date: pd.Timestamp
    legs: tuple[OptionContract, OptionContract]
    leg_actions: tuple[LegAction, LegAction]
    leg_entry_exec_per_share: tuple[float, float]
    initial_net_premium: float
    max_margin: float

    @staticmethod
    def from_legs(
        entry_date: pd.Timestamp,
        legs: list[OptionContract],
        leg_actions: list[LegAction],
    ) -> TwoLegSpreadPosition:
        if len(legs) != 2 or len(leg_actions) != 2:
            raise ValueError("Need 2 legs and 2 actions.")

        execs: list[float] = []
        credit_minus_debit = 0.0

        for leg, act in zip(legs, leg_actions):
            if act == "buy":
                px = _open_buy_per_share(leg)
                if px is None:
                    raise ValueError(f"No openable buy quote for leg K={leg.strike} exp={leg.expiration}.")
                credit_minus_debit -= px * CONTRACT_MULTIPLIER
            else:
                px = _open_sell_per_share(leg)
                if px is None:
                    raise ValueError(f"No openable sell quote for leg K={leg.strike} exp={leg.expiration}.")
                credit_minus_debit += px * CONTRACT_MULTIPLIER
            execs.append(float(px))

        k0, k1 = float(legs[0].strike), float(legs[1].strike)
        max_m = abs(k0 - k1) * CONTRACT_MULTIPLIER + max(0.0, -credit_minus_debit)

        return TwoLegSpreadPosition(
            entry_date=_norm_day(entry_date),
            legs=(legs[0], legs[1]),
            leg_actions=(leg_actions[0], leg_actions[1]),
            leg_entry_exec_per_share=(execs[0], execs[1]),
            initial_net_premium=float(credit_minus_debit),
            max_margin=float(max_m),
        )

    def all_legs_expired(self, as_of: pd.Timestamp) -> bool:
        day = _norm_day(as_of)
        return all(day >= _norm_day(c.expiration) for c in self.legs)

    def calculate_current_mtm(
        self,
        current_chain: OptionChain,
        spy_closing_price: float,
        as_of: pd.Timestamp,
        ffill_mid: dict[ContractKey, float],
    ) -> float:
        if not math.isfinite(float(spy_closing_price)):
            raise ValueError("spy_closing_price must be finite.")
        spy = float(spy_closing_price)
        day = _norm_day(as_of)
        total = 0.0

        for leg, act, ent in zip(self.legs, self.leg_actions, self.leg_entry_exec_per_share):
            exp_n = _norm_day(leg.expiration)
            expired = day >= exp_n
            k = contract_key(leg)

            live = find_contract_in_chain(
                current_chain,
                leg.expiration,
                leg.strike,
                leg.option_type,
            )

            close_act: LegAction = "sell" if act == "buy" else "buy"
            exit_px: float | None = None
            if live is not None:
                exit_px = execution_price_per_share(live, close_act, _slippage())

            if exit_px is None:
                if expired:
                    exit_px = intrinsic_per_share_leg(leg, spy)
                else:
                    mid_raw = ffill_mid.get(k)
                    if mid_raw is None and _mid_ok(leg.mid):
                        mid_raw = float(leg.mid)
                    exit_px = _exit_mark_mid_fallback(mid_raw, close_act)

            if act == "buy":
                total += (exit_px - ent) * CONTRACT_MULTIPLIER
            else:
                total += (ent - exit_px) * CONTRACT_MULTIPLIER

        return float(total)


@dataclass
class PutCreditSpreadPosition(TwoLegSpreadPosition):
    """
    Same-expiry put credit spread: ``max_loss_per_contract`` = width×100 − net credit (dollars).
    """

    max_loss_per_contract: float = 0.0

    @staticmethod
    def from_legs(
        entry_date: pd.Timestamp,
        legs: list[OptionContract],
        leg_actions: list[LegAction],
    ) -> PutCreditSpreadPosition:
        base = TwoLegSpreadPosition.from_legs(entry_date, legs, leg_actions)
        sell_k = float(legs[0].strike) if leg_actions[0] == "sell" else float(legs[1].strike)
        buy_k = float(legs[1].strike) if leg_actions[0] == "sell" else float(legs[0].strike)
        width = abs(sell_k - buy_k)
        credit = max(0.0, float(base.initial_net_premium))
        max_loss = max(width * CONTRACT_MULTIPLIER - credit, 1e-6)
        return PutCreditSpreadPosition(
            entry_date=base.entry_date,
            legs=base.legs,
            leg_actions=base.leg_actions,
            leg_entry_exec_per_share=base.leg_entry_exec_per_share,
            initial_net_premium=base.initial_net_premium,
            max_margin=base.max_margin,
            max_loss_per_contract=float(max_loss),
        )


def _legs_json_from_position_body(pos: PositionBody) -> str:
    """JSON list of ``{right, strike, expiry}`` for closed-trade logs."""
    rows: list[dict[str, Any]] = []
    if hasattr(pos, "contract"):
        c = pos.contract
        rows.append(
            {
                "right": str(c.option_type),
                "strike": float(c.strike),
                "expiry": pd.Timestamp(c.expiration).strftime("%Y-%m-%d"),
            }
        )
    elif hasattr(pos, "legs"):
        for leg in pos.legs:
            rows.append(
                {
                    "right": str(leg.option_type),
                    "strike": float(leg.strike),
                    "expiry": pd.Timestamp(leg.expiration).strftime("%Y-%m-%d"),
                }
            )
    return json.dumps(rows, separators=(",", ":"))


def _r2b_select_long_put_same_expiry(
    chain: OptionChain,
    short_leg: OptionContract,
    spot: float,
) -> OptionContract | None:
    """Long put = same expiry as short; strike ≈ short − round(spot × width_frac); must be below short."""
    width_pts = max(1.0, round(float(spot) * float(R2_SPREAD_WIDTH_FRAC_OF_SPOT)))
    target_long_k = float(short_leg.strike) - width_pts
    exp_n = pd.Timestamp(short_leg.expiration).normalize()
    typed = [
        c
        for c in chain.contracts
        if c.option_type == "P"
        and pd.Timestamp(c.expiration).normalize() == exp_n
        and float(c.strike) < float(short_leg.strike)
    ]
    if not typed:
        return None
    return min(typed, key=lambda c: abs(float(c.strike) - target_long_k))


def _r2b_ordered_short_put_candidates(
    chain: OptionChain,
    target_dte: int,
    target_delta: float,
) -> list[OptionContract]:
    """Same expiry bucket as ``find_target_leg``, puts sorted by closeness to ``target_delta`` (best first)."""
    if not chain.contracts:
        return []
    typed = [c for c in chain.contracts if c.option_type == "P"]
    if not typed:
        return []
    as_of = pd.Timestamp(chain.as_of).normalize()
    expiries: dict[pd.Timestamp, int] = {}
    for c in typed:
        exp = pd.Timestamp(c.expiration).normalize()
        dte = int((exp - as_of).days)
        if dte >= 0:
            expiries[exp] = dte
    if not expiries:
        return []
    best_exp, _ = min(expiries.items(), key=lambda kv: abs(kv[1] - int(target_dte)))
    bucket = [
        c
        for c in typed
        if pd.Timestamp(c.expiration).normalize() == best_exp and math.isfinite(float(c.delta))
    ]
    bucket.sort(key=lambda c: abs(float(c.delta) - float(target_delta)))
    return bucket


@dataclass
class ShortPutPosition:
    entry_date: pd.Timestamp
    contract: OptionContract
    entry_exec_per_share: float
    initial_net_premium: float
    max_margin: float

    @property
    def legs(self) -> tuple[OptionContract, ...]:
        return (self.contract,)

    @staticmethod
    def from_contract(entry_date: pd.Timestamp, contract: OptionContract) -> ShortPutPosition:
        px = _open_sell_per_share(contract)
        if px is None:
            raise ValueError(f"No openable sell quote for short put K={contract.strike} exp={contract.expiration}.")
        credit = float(px) * CONTRACT_MULTIPLIER
        strike = float(contract.strike)
        max_m = strike * CONTRACT_MULTIPLIER
        return ShortPutPosition(
            entry_date=_norm_day(entry_date),
            contract=contract,
            entry_exec_per_share=float(px),
            initial_net_premium=credit,
            max_margin=max_m,
        )

    def all_legs_expired(self, as_of: pd.Timestamp) -> bool:
        return _norm_day(as_of) >= _norm_day(self.contract.expiration)

    def calculate_current_mtm(
        self,
        current_chain: OptionChain,
        spy_closing_price: float,
        as_of: pd.Timestamp,
        ffill_mid: dict[ContractKey, float],
    ) -> float:
        if not math.isfinite(float(spy_closing_price)):
            raise ValueError("spy_closing_price must be finite.")
        spy = float(spy_closing_price)
        day = _norm_day(as_of)
        leg = self.contract
        ent = self.entry_exec_per_share
        exp_n = _norm_day(leg.expiration)
        expired = day >= exp_n
        k = contract_key(leg)

        live = find_contract_in_chain(
            current_chain,
            leg.expiration,
            leg.strike,
            leg.option_type,
        )

        close_act: LegAction = "buy"
        exit_px: float | None = None
        if live is not None:
            exit_px = execution_price_per_share(live, close_act, _slippage())

        if exit_px is None:
            if expired:
                exit_px = intrinsic_per_share_leg(leg, spy)
            else:
                mid_raw = ffill_mid.get(k)
                if mid_raw is None and _mid_ok(leg.mid):
                    mid_raw = float(leg.mid)
                exit_px = _exit_mark_mid_fallback(mid_raw, close_act)

        return float((ent - exit_px) * CONTRACT_MULTIPLIER)


PositionBody = Union[
    TwoLegSpreadPosition,
    PutCreditSpreadPosition,
    ShortPutPosition,
]


@dataclass
class ManagedPosition:
    regime: Regime
    qty: int
    position: PositionBody
    tp_total: float
    sl_total: float
    time_stop_days: int


@dataclass
class ClosedTrade:
    entry_date: pd.Timestamp
    exit_date: pd.Timestamp
    pnl_usd: float
    exit_reason: str
    initial_net_premium: float
    max_margin: float
    days_in_trade: int
    regime: Regime
    qty: int
    legs_json: str = ""


class _SyntheticLegsShim:
    """``.legs`` only — for :meth:`SyntheticLoader.attach_position_quotes` with multiple open positions."""

    __slots__ = ("legs",)

    def __init__(self, legs: tuple[OptionContract, ...]) -> None:
        self.legs = legs


def _position_sizing_commitment_per_contract(body: PositionBody, regime: Regime) -> float:
    """
    Per-contract risk anchor used for legacy sizing and for overlap-mode caps (must match
    ``_try_open_regime*`` floor divisors).
    """
    if regime == "pmcc":
        return max(abs(float(body.initial_net_premium)), 1e-6)
    if regime == "diagonal":
        return max(float(body.max_margin), SIZING_MIN_MAX_RISK_USD)
    if regime in ("r2_spread", "naked", "credit_spread"):
        if not isinstance(body, PutCreditSpreadPosition):
            return max(float(body.max_margin), SIZING_MIN_MAX_RISK_USD)
        return max(float(body.max_loss_per_contract), SIZING_MIN_MAX_RISK_USD)
    return max(float(body.max_margin), SIZING_MIN_MAX_RISK_USD)


class VRPBacktester:
    """R1–R4 when SPY > SMA(200); R2 is R2a diagonal + R2b PCS concurrently (separate exits per leg).

    Set ``overlap_portfolio=True`` to allow **stacked** positions: each session can add
    ``overlap_slice_contracts`` per sleeve while aggregate sleeve commitment stays within the same
    risk budget as legacy lump sizing; each position still exits on its own TP/SL/time rules.

    Set ``portfolio_mode`` to an isolated sleeve (e.g. ``\"r2_diagonal\"``) to benchmark that
    structure alone in its VIX band; ``\"full\"`` is the production 4-regime stack.

    Set ``macro_overlay_enabled=True`` to add a small **Tier A** sleeve (Yahoo returns + synthetics)
    by VIX band; see ``RenTech.strategy_stack.tier_a_series.DEFAULT_MACRO_OVERLAY_BY_BAND``.
    """

    def __init__(
        self,
        loader: Union[SyntheticLoader, IVolatilityLoader, ThetaChunksLoader],
        initial_capital: float = DEFAULT_STARTING_CAPITAL,
        *,
        spy_df: pd.DataFrame | None = None,
        spy_yfinance_start: str = DEFAULT_YF_START,
        spy_yfinance_end: str = DEFAULT_YF_END,
        slippage_factor: float | None = None,
        vol_risk_scaling: bool = True,
        r2_crossover_filters: bool = True,
        sleeve_risk_fractions: dict[Regime, float] | None = None,
        overlay_risk_fractions: dict[str, float] | None = None,
        overlay_risk_cap_frac: float | None = None,
        total_risk_cap_frac: float | None = None,
        dd_risk_scaling: bool = False,
        dd_scale_enter: float | None = None,
        dd_scale_exit: float | None = None,
        dd_scale_mult: float | None = None,
        overlap_portfolio: bool = False,
        overlap_slice_contracts: int = 1,
        disable_pmcc: bool = False,
        r2_vix_scale_contracts: bool = False,
        r2_term_structure_gate: bool = False,
        portfolio_mode: PortfolioMode = "full",
        macro_overlay_enabled: bool = False,
        macro_overlay_total_frac: float = 0.01,
        macro_overlay_requires_spy200: bool = True,
        macro_overlay_exclude_optional_etf: bool = False,
        macro_overlay_by_band: dict[str, tuple[str, ...]] | None = None,
    ) -> None:
        self.loader = loader
        self.slippage_factor = float(slippage_factor) if slippage_factor is not None else SLIPPAGE_FACTOR
        self.vol_risk_scaling = bool(vol_risk_scaling)
        self.r2_crossover_filters = bool(r2_crossover_filters)
        self.sleeve_risk_fractions = self._build_sleeve_risk_fractions(sleeve_risk_fractions)
        self.overlay_risk_fractions = self._validate_risk_fraction_map(
            overlay_risk_fractions or {},
            label="overlay",
        )
        self.overlay_risk_cap_frac = (
            float(overlay_risk_cap_frac) if overlay_risk_cap_frac is not None else None
        )
        self.total_risk_cap_frac = float(total_risk_cap_frac) if total_risk_cap_frac is not None else None
        if self.overlay_risk_cap_frac is not None and not (
            0.0 < self.overlay_risk_cap_frac <= 1.0
        ):
            raise ValueError("overlay_risk_cap_frac must be in (0, 1]")
        if self.total_risk_cap_frac is not None and not (0.0 < self.total_risk_cap_frac <= 1.0):
            raise ValueError("total_risk_cap_frac must be in (0, 1]")
        if self.overlay_risk_cap_frac is not None:
            ov_sum = sum(self.overlay_risk_fractions.values())
            if ov_sum > self.overlay_risk_cap_frac + 1e-12:
                raise ValueError(
                    f"sum(overlay_risk_fractions)={ov_sum:.6f} exceeds overlay_risk_cap_frac="
                    f"{self.overlay_risk_cap_frac:.6f}"
                )
        if self.total_risk_cap_frac is not None:
            core_max = max(self.sleeve_risk_fractions.values())
            ov_sum = sum(self.overlay_risk_fractions.values())
            if core_max + ov_sum > self.total_risk_cap_frac + 1e-12:
                raise ValueError(
                    f"max(core sleeve risk frac)+sum(overlay risk fracs)={core_max + ov_sum:.6f} "
                    f"exceeds total_risk_cap_frac={self.total_risk_cap_frac:.6f}"
                )
        self.dd_risk_scaling = bool(dd_risk_scaling)
        self._dd_scale_enter = float(
            dd_scale_enter if dd_scale_enter is not None else DD_SCALE_ENTER_DEFAULT
        )
        self._dd_scale_exit = float(
            dd_scale_exit if dd_scale_exit is not None else DD_SCALE_EXIT_DEFAULT
        )
        self._dd_scale_mult = float(
            dd_scale_mult if dd_scale_mult is not None else DD_SCALE_MULT_DEFAULT
        )
        if not (0.0 < self._dd_scale_exit < self._dd_scale_enter < 1.0):
            raise ValueError(
                "dd_scale thresholds: need 0 < dd_scale_exit < dd_scale_enter < 1 "
                f"(got exit={self._dd_scale_exit}, enter={self._dd_scale_enter})"
            )
        if not (0.0 < self._dd_scale_mult <= 1.0):
            raise ValueError("dd_scale_mult must be in (0, 1]")
        self.overlap_portfolio = bool(overlap_portfolio)
        _os = int(overlap_slice_contracts)
        if _os < 1:
            raise ValueError("overlap_slice_contracts must be >= 1")
        self.overlap_slice_contracts = _os
        self.disable_pmcc = bool(disable_pmcc)
        self.r2_vix_scale_contracts = bool(r2_vix_scale_contracts)
        self.r2_term_structure_gate = bool(r2_term_structure_gate)
        _pm = str(portfolio_mode).strip()
        if _pm not in _VALID_PORTFOLIO_MODES:
            raise ValueError(
                f"portfolio_mode must be one of {sorted(_VALID_PORTFOLIO_MODES)}, got {_pm!r}"
            )
        self.portfolio_mode = cast(PortfolioMode, _pm)
        self.macro_overlay_enabled = bool(macro_overlay_enabled)
        _mf = float(macro_overlay_total_frac)
        if not (0.0 <= _mf <= 0.25):
            raise ValueError("macro_overlay_total_frac must be in [0, 0.25]")
        self.macro_overlay_total_frac = _mf
        self.macro_overlay_requires_spy200 = bool(macro_overlay_requires_spy200)
        self.macro_overlay_exclude_optional_etf = bool(macro_overlay_exclude_optional_etf)
        self.macro_overlay_by_band = self._coerce_macro_overlay_by_band(macro_overlay_by_band)
        self._macro_returns_df: pd.DataFrame | None = None
        self._macro_overlay_pnl_usd: float = 0.0
        self._equity_peak: float = float(initial_capital)
        self._dd_reduced_mode: bool = False
        self.initial_capital = float(initial_capital)
        self.current_capital = float(initial_capital)
        self.active_positions: list[ManagedPosition] = []
        self.trade_log: list[ClosedTrade] = []
        self._ffill_mid: dict[ContractKey, float] = {}
        self._equity_curve: list[tuple[pd.Timestamp, float]] = []

        if spy_df is not None:
            self._spy_df = normalize_spy_df(spy_df)
        else:
            self._spy_df = normalize_spy_df(
                load_spy_vix_from_yfinance(spy_yfinance_start, spy_yfinance_end)
            )

    @staticmethod
    def _validate_risk_fraction_map(
        mapping: dict[str, float],
        *,
        label: str,
    ) -> dict[str, float]:
        out: dict[str, float] = {}
        for k, v in mapping.items():
            name = str(k).strip()
            if not name:
                raise ValueError(f"{label} risk fraction key cannot be empty")
            fv = float(v)
            if not (math.isfinite(fv) and fv > 0.0 and fv <= 1.0):
                raise ValueError(
                    f"{label} risk fraction for '{name}' must be finite and in (0, 1], got {v}"
                )
            out[name] = fv
        return out

    def _build_sleeve_risk_fractions(
        self,
        custom: dict[Regime, float] | None,
    ) -> dict[Regime, float]:
        base = dict(DEFAULT_SLEEVE_RISK_FRACTIONS)
        if custom:
            clean = self._validate_risk_fraction_map(dict(custom), label="sleeve")
            for k, v in clean.items():
                if k not in base:
                    raise ValueError(
                        f"Unknown sleeve '{k}'. Valid sleeves: {', '.join(base.keys())}"
                    )
                base[k] = v
        return {k: float(v) for k, v in base.items()}

    @staticmethod
    def _coerce_macro_overlay_by_band(
        raw: dict[str, tuple[str, ...]] | None,
    ) -> dict[str, tuple[str, ...]]:
        from RenTech.strategy_stack.tier_a_series import (
            DEFAULT_MACRO_OVERLAY_BY_BAND,
            MACRO_OVERLAY_ALLOWED_IDS,
        )

        if raw is None:
            return {k: tuple(v) for k, v in DEFAULT_MACRO_OVERLAY_BY_BAND.items()}
        out: dict[str, tuple[str, ...]] = {}
        for k, tup in raw.items():
            kk = str(k).strip().lower()
            if kk not in ("r1", "r2", "r3", "r4"):
                raise ValueError("macro_overlay_by_band keys must be 'r1', 'r2', 'r3', or 'r4'")
            cols: list[str] = []
            for c in tup:
                cs = str(c).strip()
                if cs not in MACRO_OVERLAY_ALLOWED_IDS:
                    raise ValueError(
                        f"macro overlay id {cs!r} not in allowed set {sorted(MACRO_OVERLAY_ALLOWED_IDS)}"
                    )
                cols.append(cs)
            out[kk] = tuple(cols)
        return out

    def _vix_macro_band_key(self, vx: float) -> str | None:
        if not math.isfinite(vx):
            return None
        if vx < VIX_R1_MAX:
            return "r1"
        if vx <= VIX_R2_MAX:
            return "r2"
        if vx <= VIX_R3_MAX:
            return "r3"
        return "r4"

    def _apply_macro_overlay(
        self,
        as_of: pd.Timestamp,
        spy_close: float,
        spy_sma200: object,
        vix_close: object,
    ) -> None:
        """
        Additive cash bump: ``capital += capital * macro_overlay_total_frac * mean(daily returns)``
        for Tier A columns registered for this VIX band (see ``tier_a_series`` defaults).
        """
        if not self.macro_overlay_enabled:
            return
        if self._macro_returns_df is None or self._macro_returns_df.empty:
            return
        if self.macro_overlay_total_frac <= 0.0:
            return
        if not (math.isfinite(float(spy_close)) and pd.notna(vix_close) and math.isfinite(float(vix_close))):
            return
        vx = float(vix_close)
        if self.macro_overlay_requires_spy200:
            if not (pd.notna(spy_sma200) and math.isfinite(float(spy_sma200))):
                return
            if not (float(spy_close) > float(spy_sma200)):
                return
        band = self._vix_macro_band_key(vx)
        if band is None:
            return
        names = self.macro_overlay_by_band.get(band)
        if not names:
            return
        d = _norm_day(as_of)
        if d not in self._macro_returns_df.index:
            return
        row = self._macro_returns_df.loc[d]
        rs: list[float] = []
        for c in names:
            if c not in self._macro_returns_df.columns:
                continue
            z = row[c]
            if pd.notna(z) and math.isfinite(float(z)):
                rs.append(float(z))
        if not rs:
            return
        r_bar = float(sum(rs) / len(rs))
        c0 = float(self.current_capital)
        bump = c0 * float(self.macro_overlay_total_frac) * r_bar
        self.current_capital = c0 + bump
        self._macro_overlay_pnl_usd += bump
        self._equity_curve.append((as_of, self.current_capital))

    def _target_risk_usd(
        self,
        sleeve: Regime,
        *,
        capital: float,
        dd_mult: float,
        global_mult: float = 1.0,
        regime_mult: float = 1.0,
    ) -> float:
        rf = float(self.sleeve_risk_fractions[sleeve])
        return float(capital) * rf * float(dd_mult) * float(global_mult) * float(regime_mult)

    def _total_sizing_commitment_regime(self, regime: Regime) -> float:
        return sum(
            _position_sizing_commitment_per_contract(mp.position, mp.regime) * float(mp.qty)
            for mp in self.active_positions
            if mp.regime == regime
        )

    def overlay_target_risk_usd(
        self,
        overlay_name: str,
        *,
        capital: float | None = None,
        dd_mult: float = 1.0,
        global_mult: float = 1.0,
        regime_mult: float = 1.0,
    ) -> float:
        """
        Risk budget helper for true hedge overlays.

        Overlay-specific risk fractions are independent of core sleeves and scale with account equity.
        """
        name = str(overlay_name).strip()
        if name not in self.overlay_risk_fractions:
            return 0.0
        cap = float(self.current_capital if capital is None else capital)
        return cap * float(self.overlay_risk_fractions[name]) * float(dd_mult) * float(global_mult) * float(
            regime_mult
        )

    def _unwrap_for_synthetic(self) -> _SyntheticLegsShim | None:
        if not self.active_positions:
            return None
        legs: list[OptionContract] = []
        for mp in self.active_positions:
            legs.extend(mp.position.legs)
        return _SyntheticLegsShim(tuple(legs)) if legs else None

    def _refresh_ffill_from_chain(self, chain: OptionChain) -> None:
        for c in chain.contracts:
            if _mid_ok(c.mid):
                self._ffill_mid[contract_key(c)] = float(c.mid)

    def _seed_ffill_from_position(self, pos: PositionBody) -> None:
        for leg in pos.legs:
            if _mid_ok(leg.mid):
                self._ffill_mid[contract_key(leg)] = float(leg.mid)

    def _all_legs_expired(self, mp: ManagedPosition, as_of: pd.Timestamp) -> bool:
        return mp.position.all_legs_expired(as_of)

    def _vol_risk_multipliers(self, d: pd.Timestamp) -> tuple[float, float]:
        """
        Returns (global_mult, r2_extra_mult) applied to target_risk.
        R2 uses global_mult * r2_extra_mult; other regimes use global_mult only.
        """
        if not self.vol_risk_scaling:
            return (1.0, 1.0)
        sdf = self._spy_df
        if "vvix_close" not in sdf.columns:
            return (1.0, 1.0)
        row = sdf.loc[d]
        g = 1.0
        r2 = 1.0
        vv = row.get("vvix_close")
        vm = row.get("vvix_med_20")
        if pd.notna(vv) and pd.notna(vm) and float(vm) > 0.0:
            if float(vv) > float(vm) * VVIX_GLOBAL_THRESHOLD_MULT:
                g = VVIX_GLOBAL_RISK_SCALE
        r2_stress = False
        ch = row.get("vix_chg_5d")
        if "vix_chg_5d" in sdf.columns and pd.notna(ch) and float(ch) > VIX_R2_CHANGE_THRESH:
            r2_stress = True
        if pd.notna(vv) and pd.notna(vm) and float(vm) > 0.0:
            if float(vv) > float(vm) * R2_VVIX_STRESS_MULT:
                r2_stress = True
        if r2_stress:
            r2 = R2_STRESS_RISK_SCALE
        return (g, r2)

    def _r2_crossover_entry_ok(self, d: pd.Timestamp, vx: float) -> bool:
        """
        R2-only: apply SPY / VIX ratio gates only when ``12 <= VIX <= 20`` (the R2 band). Outside
        that range, return True (defensive). In this band, extra gates complement the global
        SPY>SMA(200) rule for R2 (diagonal + put spread). Complacency (VIX<12) and elevated fear (VIX>20) use
        other regimes.

        Optional ``r2_term_structure_gate``: block entries when VIX9D (9-day) > VIX (30-day), i.e.
        the front end of the VIX term structure is inverted — a near-term fear signal that makes
        short-vol R2 entries more likely to be stopped out.
        """
        if not self.r2_crossover_filters:
            # Term structure gate is independent of the crossover filter toggle.
            if self.r2_term_structure_gate:
                sdf = self._spy_df
                row = sdf.loc[d]
                vix9d = row.get("vix9d_close")
                if pd.notna(vix9d) and math.isfinite(float(vix9d)) and float(vix9d) > vx:
                    return False
            return True
        if not (float(VIX_R1_MAX) <= vx <= float(VIX_R2_MAX)):
            return True
        sdf = self._spy_df
        row = sdf.loc[d]
        # VIX term structure gate: VIX9D > VIX30 ⇒ front-end inverted, skip new R2 entries.
        if self.r2_term_structure_gate:
            vix9d = row.get("vix9d_close")
            if pd.notna(vix9d) and math.isfinite(float(vix9d)) and float(vix9d) > vx:
                return False
        spy_px = float(row["close"])
        col_spy = f"sma_{R2_SPY_MA_SHORT_DAYS}"
        if col_spy in sdf.columns:
            s_short = row.get(col_spy)
            if pd.notna(s_short) and math.isfinite(float(s_short)):
                if spy_px <= float(s_short):
                    return False
        vma = row.get(f"vix_sma_{R2_VIX_SLOW_MA_DAYS}")
        vmx = row.get("vix_max_50")
        if (
            pd.notna(vma)
            and pd.notna(vmx)
            and math.isfinite(float(vma))
            and math.isfinite(float(vmx))
        ):
            denom = max(float(vmx), 1e-6)
            ratio = float(vma) / denom
            if ratio >= float(R2_VIX_MA_TO_MAX_BLOCK_ABOVE):
                return False
        return True

    def _update_dd_state_and_mult(self, marked_equity: float) -> float:
        """
        Update peak / reduced-mode flag from **marked** portfolio equity (cash + open MTM).
        Returns a multiplier in ``(0, 1]`` applied to **new-entry** risk (all regimes).
        """
        if not self.dd_risk_scaling:
            return 1.0
        me = float(marked_equity)
        if not math.isfinite(me):
            return self._dd_scale_mult if self._dd_reduced_mode else 1.0
        self._equity_peak = max(self._equity_peak, me)
        peak = self._equity_peak
        dd = (peak - me) / peak if peak > 1e-12 else 0.0
        if not self._dd_reduced_mode:
            if dd >= self._dd_scale_enter:
                self._dd_reduced_mode = True
        else:
            if dd <= self._dd_scale_exit:
                self._dd_reduced_mode = False
        return self._dd_scale_mult if self._dd_reduced_mode else 1.0

    def _r2_scaled_contracts(self, vx: float, base: int | None) -> int | None:
        """
        When ``r2_vix_scale_contracts`` is enabled, scale the per-day contract slice for R2
        entries proportionally to VIX level. Higher VIX ⟹ richer premium ⟹ deploy more contracts.

        Formula: ``max(1, round(base * vx / VIX_R1_MAX))``.
        At VIX=12 (band floor): 1× base. At VIX=20 (band ceiling): ~1.67× base.
        If ``base`` is None (legacy non-overlap sizing), return None unchanged.
        """
        if not self.r2_vix_scale_contracts or base is None:
            return base
        return max(1, round(base * vx / float(VIX_R1_MAX)))

    def _collect_new_positions_for_day(
        self,
        today: pd.Timestamp,
        d: pd.Timestamp,
        chain: OptionChain,
        *,
        spy_close: float,
        spy_sma200: float,
        vix_close: float,
        dd_mult: float,
        overlap_slice_contracts: int | None,
    ) -> list[ManagedPosition]:
        """
        When ``overlap_slice_contracts`` is set, each sleeve adds that many contracts per day
        subject to the same ``target_risk_usd`` cap as legacy lump sizing. When ``None``, legacy
        lump sizing (floor of risk / per-contract commitment) applies.
        """
        out: list[ManagedPosition] = []
        if not (math.isfinite(spy_close) and pd.notna(spy_sma200) and math.isfinite(float(spy_sma200))):
            return out
        if not (pd.notna(vix_close) and math.isfinite(float(vix_close))):
            return out
        if not (spy_close > float(spy_sma200)):
            return out

        vx = float(vix_close)
        g_mult, r2_mult = self._vol_risk_multipliers(d)
        if vx < VIX_R1_MAX:
            g_mult = 1.0

        pm = self.portfolio_mode

        if pm == "r1_strangle":
            if vx >= VIX_R1_MAX or self.disable_pmcc:
                return out
            one = self._try_open_regime1_weekly_strangle(
                today,
                chain,
                self._target_risk_usd(
                    "pmcc",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=1.0,
                ),
                overlap_slice_contracts=overlap_slice_contracts,
            )
            if one is not None:
                out.append(one)
            return out

        if pm == "r2_diagonal":
            if not (VIX_R1_MAX <= vx <= VIX_R2_MAX):
                return out
            if not self._r2_crossover_entry_ok(d, vx):
                return out
            r2_sl = self._r2_scaled_contracts(vx, overlap_slice_contracts)
            diag_risk = self._target_risk_usd(
                "diagonal",
                capital=self.current_capital,
                dd_mult=dd_mult,
                global_mult=g_mult,
                regime_mult=r2_mult,
            )
            r2a = self._try_open_regime2_diagonal(
                today,
                chain,
                diag_risk,
                overlap_slice_contracts=r2_sl,
            )
            if r2a is not None:
                out.append(r2a)
            return out

        if pm == "r2_spread":
            if not (VIX_R1_MAX <= vx <= VIX_R2_MAX):
                return out
            if not self._r2_crossover_entry_ok(d, vx):
                return out
            r2_sl = self._r2_scaled_contracts(vx, overlap_slice_contracts)
            spread_risk = self._target_risk_usd(
                "r2_spread",
                capital=self.current_capital,
                dd_mult=dd_mult,
                global_mult=g_mult,
                regime_mult=r2_mult,
            )
            r2b = self._try_open_regime2_put_spread_b(
                today,
                chain,
                spread_risk,
                spy_close,
                overlap_slice_contracts=r2_sl,
            )
            if r2b is not None:
                out.append(r2b)
            return out

        if pm == "r2_pair":
            if not (VIX_R1_MAX <= vx <= VIX_R2_MAX):
                return out
            if not self._r2_crossover_entry_ok(d, vx):
                return out
            r2_sl = self._r2_scaled_contracts(vx, overlap_slice_contracts)
            spread_risk = self._target_risk_usd(
                "r2_spread",
                capital=self.current_capital,
                dd_mult=dd_mult,
                global_mult=g_mult,
                regime_mult=r2_mult,
            )
            diag_risk = self._target_risk_usd(
                "diagonal",
                capital=self.current_capital,
                dd_mult=dd_mult,
                global_mult=g_mult,
                regime_mult=r2_mult,
            )
            r2a = self._try_open_regime2_diagonal(
                today,
                chain,
                diag_risk,
                overlap_slice_contracts=r2_sl,
            )
            r2b = self._try_open_regime2_put_spread_b(
                today,
                chain,
                spread_risk,
                spy_close,
                overlap_slice_contracts=r2_sl,
            )
            if r2a is not None and r2b is not None:
                out.extend([r2a, r2b])
            return out

        if pm == "r3_put_spread":
            if not (vx > VIX_R2_MAX and vx <= VIX_R3_MAX):
                return out
            one = self._try_open_regime3_put_spread(
                today,
                chain,
                self._target_risk_usd(
                    "naked",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                ),
                overlap_slice_contracts=overlap_slice_contracts,
            )
            if one is not None:
                out.append(one)
            return out

        if pm == "r4_credit_spread":
            if vx <= VIX_R3_MAX:
                return out
            one = self._try_open_regime4_credit_spread(
                today,
                chain,
                self._target_risk_usd(
                    "credit_spread",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                ),
                overlap_slice_contracts=overlap_slice_contracts,
            )
            if one is not None:
                out.append(one)
            return out

        # --- portfolio_mode == "full" (production ladder) ---
        if vx < VIX_R1_MAX:
            if not self.disable_pmcc:
                one = self._try_open_regime1_weekly_strangle(
                    today,
                    chain,
                    self._target_risk_usd(
                        "pmcc",
                        capital=self.current_capital,
                        dd_mult=dd_mult,
                        global_mult=g_mult,
                    ),
                    overlap_slice_contracts=overlap_slice_contracts,
                )
                if one is not None:
                    out.append(one)
        elif vx <= VIX_R2_MAX:
            if self._r2_crossover_entry_ok(d, vx):
                r2_sl = self._r2_scaled_contracts(vx, overlap_slice_contracts)
                spread_risk = self._target_risk_usd(
                    "r2_spread",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                    regime_mult=r2_mult,
                )
                diag_risk = self._target_risk_usd(
                    "diagonal",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                    regime_mult=r2_mult,
                )
                r2a = self._try_open_regime2_diagonal(
                    today,
                    chain,
                    diag_risk,
                    overlap_slice_contracts=r2_sl,
                )
                r2b = self._try_open_regime2_put_spread_b(
                    today,
                    chain,
                    spread_risk,
                    spy_close,
                    overlap_slice_contracts=r2_sl,
                )
                if r2a is not None and r2b is not None:
                    out.extend([r2a, r2b])
        elif vx <= VIX_R3_MAX:
            one = self._try_open_regime3_put_spread(
                today,
                chain,
                self._target_risk_usd(
                    "naked",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                ),
                overlap_slice_contracts=overlap_slice_contracts,
            )
            if one is not None:
                out.append(one)
        else:
            one = self._try_open_regime4_credit_spread(
                today,
                chain,
                self._target_risk_usd(
                    "credit_spread",
                    capital=self.current_capital,
                    dd_mult=dd_mult,
                    global_mult=g_mult,
                ),
                overlap_slice_contracts=overlap_slice_contracts,
            )
            if one is not None:
                out.append(one)
        return out

    def run_backtest(
        self,
        trading_days: list[pd.Timestamp] | None = None,
        *,
        show_progress: bool = True,
    ) -> list[ClosedTrade]:
        sdf = self._spy_df
        # ThetaChunksLoader must use the same normalized SPY panel as the engine (index quirks).
        if isinstance(self.loader, ThetaChunksLoader):
            self.loader.spy_df = sdf

        self.current_capital = self.initial_capital
        self._equity_curve = [(sdf.index.min(), self.initial_capital)]
        self._equity_peak = float(self.initial_capital)
        self._dd_reduced_mode = False
        self._macro_overlay_pnl_usd = 0.0
        self._macro_returns_df = None
        if self.macro_overlay_enabled:
            from RenTech.strategy_stack.tier_a_series import load_macro_returns_aligned

            self._macro_returns_df = load_macro_returns_aligned(
                sdf,
                exclude_optional_etf=self.macro_overlay_exclude_optional_etf,
            )
            if self._macro_returns_df.empty:
                warnings.warn(
                    "macro_overlay_enabled but Yahoo macro return matrix is empty; overlay is inert.",
                    stacklevel=2,
                )

        if trading_days is None:
            days = sorted({_norm_day(d) for d in self.loader.iter_chain_dates()})
        else:
            days = sorted({_norm_day(d) for d in trading_days})

        day_iter: Any = days
        if show_progress and days:
            day_iter = tqdm(
                days,
                desc="VRP backtest",
                unit="day",
                mininterval=0.3,
                smoothing=0.05,
            )

        prev_sf = _active_slippage
        _set_active_slippage(self.slippage_factor)
        try:
            for today in day_iter:
                d = _norm_day(today)
                if d not in sdf.index:
                    warnings.warn(f"No SPY row for {d.date()}; skipping day.", stacklevel=2)
                    continue
                spy_close = float(sdf.loc[d, "close"])
                spy_sma200 = sdf.loc[d, "sma_200"]
                vix_close = sdf.loc[d, "vix_close"]

                chain = self.loader.get_chain_for_date(today)
                if isinstance(self.loader, SyntheticLoader):
                    self.loader.attach_position_quotes(chain, self._unwrap_for_synthetic())

                if chain.contracts:
                    self._refresh_ffill_from_chain(chain)
                elif self.active_positions and not isinstance(chain, SyntheticOptionChain):
                    warnings.warn(
                        f"Empty option chain on {today.date()}; MTM uses ffill / intrinsic only.",
                        stacklevel=2,
                    )
                    chain = OptionChain(as_of=d, contracts=[])

                open_pnl_total = 0.0
                for mp0 in self.active_positions:
                    pnl_one0 = mp0.position.calculate_current_mtm(
                        chain,
                        spy_closing_price=spy_close,
                        as_of=d,
                        ffill_mid=self._ffill_mid,
                    )
                    open_pnl_total += pnl_one0 * float(mp0.qty)

                marked_equity = self.current_capital + open_pnl_total
                dd_mult = self._update_dd_state_and_mult(marked_equity)

                still_open: list[ManagedPosition] = []
                for mp in self.active_positions:
                    pos = mp.position
                    days_in = int((today - pos.entry_date).days)
                    pnl_one = pos.calculate_current_mtm(
                        chain,
                        spy_closing_price=spy_close,
                        as_of=d,
                        ffill_mid=self._ffill_mid,
                    )
                    pnl_total = pnl_one * float(mp.qty)

                    reason: str | None = None
                    if pnl_total >= mp.tp_total:
                        reason = "take_profit"
                    elif pnl_total <= mp.sl_total:
                        reason = "stop_loss"

                    if reason is None and self._all_legs_expired(mp, d):
                        reason = "all_legs_expired"
                    elif reason is None and days_in >= mp.time_stop_days:
                        reason = "time_stop"

                    if reason is not None:
                        self.current_capital += pnl_total
                        self.trade_log.append(
                            ClosedTrade(
                                entry_date=pos.entry_date,
                                exit_date=today,
                                pnl_usd=pnl_total,
                                exit_reason=reason,
                                initial_net_premium=float(pos.initial_net_premium) * mp.qty,
                                max_margin=pos.max_margin * mp.qty,
                                days_in_trade=days_in,
                                regime=mp.regime,
                                qty=mp.qty,
                                legs_json=_legs_json_from_position_body(pos),
                            )
                        )
                        self._equity_curve.append((today, self.current_capital))
                    else:
                        still_open.append(mp)
                self.active_positions = still_open

                if self.overlap_portfolio:
                    new_ps = self._collect_new_positions_for_day(
                        today,
                        d,
                        chain,
                        spy_close=spy_close,
                        spy_sma200=spy_sma200,
                        vix_close=vix_close,
                        dd_mult=dd_mult,
                        overlap_slice_contracts=self.overlap_slice_contracts,
                    )
                    for op in new_ps:
                        self._seed_ffill_from_position(op.position)
                        self.active_positions.append(op)
                elif not self.active_positions:
                    new_ps = self._collect_new_positions_for_day(
                        today,
                        d,
                        chain,
                        spy_close=spy_close,
                        spy_sma200=spy_sma200,
                        vix_close=vix_close,
                        dd_mult=dd_mult,
                        overlap_slice_contracts=None,
                    )
                    if new_ps:
                        for op in new_ps:
                            self._seed_ffill_from_position(op.position)
                        self.active_positions = new_ps

                self._apply_macro_overlay(today, spy_close, spy_sma200, vix_close)

        finally:
            _set_active_slippage(prev_sf)

        return self.trade_log

    def _open_two_leg(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        specs: list[tuple[int, float, LegAction]],
        *,
        option_type: str = "P",
    ) -> TwoLegSpreadPosition | None:
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        legs: list[OptionContract] = []
        actions: list[LegAction] = []
        for dte, delta, action in specs:
            try:
                leg = chain.find_target_leg(
                    target_dte=dte,
                    target_delta=delta,
                    option_type=option_type,
                )
            except ValueError as e:
                warnings.warn(f"Two-leg open failed on {today.date()}: {e}", stacklevel=2)
                return None
            if not _mid_ok(leg.mid) and not (_quote_ok(leg.bid) or _quote_ok(leg.ask)):
                return None
            legs.append(leg)
            actions.append(action)
        try:
            return TwoLegSpreadPosition.from_legs(today, legs, actions)
        except ValueError as e:
            warnings.warn(f"Two-leg open failed on {today.date()}: {e}", stacklevel=2)
            return None

    def _try_open_regime1_weekly_strangle(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        target_risk_usd: float,
        *,
        overlap_slice_contracts: int | None = None,
    ) -> ManagedPosition | None:
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        pair = find_weekly_long_strangle_same_expiry(
            chain,
            R1_STRANGLE_DTE,
            R1_CALL_DELTA,
            R1_PUT_DELTA,
        )
        if pair is None:
            return None
        call_leg, put_leg = pair
        legs = [call_leg, put_leg]
        for leg in legs:
            if not _mid_ok(leg.mid) and not (_quote_ok(leg.bid) or _quote_ok(leg.ask)):
                return None
        try:
            body = TwoLegSpreadPosition.from_legs(today, legs, ["buy", "buy"])
        except ValueError as e:
            warnings.warn(
                f"R1 weekly strangle open failed on {today.date()}: {e}",
                stacklevel=2,
            )
            return None
        debit = abs(float(body.initial_net_premium))
        if debit < 1e-6:
            return None
        max_risk = debit
        if overlap_slice_contracts is None:
            qty = max(1, int(math.floor(target_risk_usd / max_risk)))
        else:
            sl = max(1, int(overlap_slice_contracts))
            if self._total_sizing_commitment_regime("pmcc") + max_risk * sl > target_risk_usd + 1e-9:
                return None
            qty = sl
        tp_total = R1_TP_FRAC * debit * qty
        sl_total = R1_SL_FRAC * debit * qty
        return ManagedPosition(
            regime="pmcc",
            qty=qty,
            position=body,
            tp_total=tp_total,
            sl_total=sl_total,
            time_stop_days=R1_TIME_STOP_DAYS,
        )

    def _try_open_regime2_diagonal(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        target_risk_usd: float,
        *,
        overlap_slice_contracts: int | None = None,
    ) -> ManagedPosition | None:
        """R2a — put diagonal only (same economics as pre-composite R2)."""
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        body = self._open_two_leg(today, chain, list(R2_DIAG_LEG_SPECS))
        if body is None:
            return None
        # Derive per-contract risk from the opened diagonal itself instead of a fixed constant.
        # For TwoLegSpreadPosition this is a conservative margin proxy:
        #   width*100 + max(0, debit_paid)
        diag_risk_per_contract = max(float(body.max_margin), SIZING_MIN_MAX_RISK_USD)
        if overlap_slice_contracts is None:
            qty = max(
                1,
                int(
                    math.floor(
                        target_risk_usd / diag_risk_per_contract
                    )
                ),
            )
        else:
            sl = max(1, int(overlap_slice_contracts))
            if (
                self._total_sizing_commitment_regime("diagonal") + diag_risk_per_contract * sl
                > target_risk_usd + 1e-9
            ):
                return None
            qty = sl
        tp_total = R2_TP_PER_CONTRACT * qty
        sl_total = R2_SL_PER_CONTRACT * qty
        return ManagedPosition(
            regime="diagonal",
            qty=qty,
            position=body,
            tp_total=tp_total,
            sl_total=sl_total,
            time_stop_days=R2_TIME_STOP_DAYS,
        )

    def _try_open_regime2_put_spread_b(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        target_risk_usd: float,
        spy_close: float,
        *,
        overlap_slice_contracts: int | None = None,
    ) -> ManagedPosition | None:
        """R2b — put credit spread: short ~−22Δ @ ~30D; long same expiry at short − round(spot×width_frac)."""
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        if not math.isfinite(float(spy_close)) or float(spy_close) <= 0:
            return None
        candidates = _r2b_ordered_short_put_candidates(
            chain, int(R2_SPREAD_TARGET_DTE), float(R2_SPREAD_SHORT_DELTA)
        )
        short_leg: OptionContract | None = None
        long_leg: OptionContract | None = None
        for cand in candidates:
            if not _mid_ok(cand.mid) and not (_quote_ok(cand.bid) or _quote_ok(cand.ask)):
                continue
            lg = _r2b_select_long_put_same_expiry(chain, cand, float(spy_close))
            if lg is None:
                continue
            if not _mid_ok(lg.mid) and not (_quote_ok(lg.bid) or _quote_ok(lg.ask)):
                continue
            short_leg, long_leg = cand, lg
            break
        if short_leg is None or long_leg is None:
            warnings.warn(
                f"R2b put spread: no short/long pair on {today.date()} "
                f"(tried {len(candidates)} short candidates)",
                stacklevel=2,
            )
            return None
        legs = [short_leg, long_leg]
        actions = ["sell", "buy"]
        try:
            body = PutCreditSpreadPosition.from_legs(today, legs, actions)
        except ValueError as e:
            warnings.warn(f"R2b put spread open failed on {today.date()}: {e}", stacklevel=2)
            return None
        credit = max(0.0, float(body.initial_net_premium))
        max_risk = float(body.max_loss_per_contract)
        per_c = max(max_risk, SIZING_MIN_MAX_RISK_USD)
        if overlap_slice_contracts is None:
            qty = max(
                1,
                int(
                    math.floor(
                        target_risk_usd / per_c
                    )
                ),
            )
        else:
            sl = max(1, int(overlap_slice_contracts))
            if self._total_sizing_commitment_regime("r2_spread") + per_c * sl > target_risk_usd + 1e-9:
                return None
            qty = sl
        tp_total = R2_SPREAD_TP_FRAC * credit * qty
        sl_total = -1.0 * max_risk * qty
        return ManagedPosition(
            regime="r2_spread",
            qty=qty,
            position=body,
            tp_total=tp_total,
            sl_total=sl_total,
            time_stop_days=R2_SPREAD_TIME_STOP_DAYS,
        )

    def _try_open_regime3_put_spread(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        target_risk_usd: float,
        *,
        overlap_slice_contracts: int | None = None,
    ) -> ManagedPosition | None:
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        legs: list[OptionContract] = []
        actions: list[LegAction] = []
        for dte, delta, action in R3_CREDIT_LEG_SPECS:
            try:
                leg = chain.find_target_leg(
                    target_dte=dte,
                    target_delta=delta,
                    option_type="P",
                )
            except ValueError as e:
                warnings.warn(f"R3 put spread open failed on {today.date()}: {e}", stacklevel=2)
                return None
            if not _mid_ok(leg.mid) and not (_quote_ok(leg.bid) or _quote_ok(leg.ask)):
                return None
            legs.append(leg)
            actions.append(action)
        try:
            body = PutCreditSpreadPosition.from_legs(today, legs, actions)
        except ValueError as e:
            warnings.warn(f"R3 put spread open failed on {today.date()}: {e}", stacklevel=2)
            return None
        credit = max(0.0, float(body.initial_net_premium))
        max_risk = float(body.max_loss_per_contract)
        per_c = max(max_risk, SIZING_MIN_MAX_RISK_USD)
        if overlap_slice_contracts is None:
            qty = max(
                1,
                int(
                    math.floor(
                        target_risk_usd / per_c
                    )
                ),
            )
        else:
            sl = max(1, int(overlap_slice_contracts))
            if self._total_sizing_commitment_regime("naked") + per_c * sl > target_risk_usd + 1e-9:
                return None
            qty = sl
        tp_total = R3_TP_FRAC * credit * qty
        sl_total = -1.0 * max_risk * qty
        return ManagedPosition(
            regime="naked",
            qty=qty,
            position=body,
            tp_total=tp_total,
            sl_total=sl_total,
            time_stop_days=R3_TIME_STOP_DAYS,
        )

    def _try_open_regime4_credit_spread(
        self,
        today: pd.Timestamp,
        chain: OptionChain,
        target_risk_usd: float,
        *,
        overlap_slice_contracts: int | None = None,
    ) -> ManagedPosition | None:
        if not chain.contracts and not isinstance(chain, SyntheticOptionChain):
            return None
        legs: list[OptionContract] = []
        actions: list[LegAction] = []
        for dte, delta, action in R4_CREDIT_LEG_SPECS:
            try:
                leg = chain.find_target_leg(
                    target_dte=dte,
                    target_delta=delta,
                    option_type="P",
                )
            except ValueError as e:
                warnings.warn(f"Credit spread open failed on {today.date()}: {e}", stacklevel=2)
                return None
            if not _mid_ok(leg.mid) and not (_quote_ok(leg.bid) or _quote_ok(leg.ask)):
                return None
            legs.append(leg)
            actions.append(action)
        try:
            body = PutCreditSpreadPosition.from_legs(today, legs, actions)
        except ValueError as e:
            warnings.warn(f"Credit spread open failed on {today.date()}: {e}", stacklevel=2)
            return None
        credit = max(0.0, float(body.initial_net_premium))
        # max_risk = (strike_width * 100) - (|initial_net_premium| * 100) per user sheet;
        # with dollars: width*100 - credit (same as max_loss_per_contract)
        max_risk = float(body.max_loss_per_contract)
        per_c = max(max_risk, SIZING_MIN_MAX_RISK_USD)
        if overlap_slice_contracts is None:
            qty = max(
                1,
                int(
                    math.floor(
                        target_risk_usd / per_c
                    )
                ),
            )
        else:
            sl = max(1, int(overlap_slice_contracts))
            if self._total_sizing_commitment_regime("credit_spread") + per_c * sl > target_risk_usd + 1e-9:
                return None
            qty = sl
        tp_total = R4_TP_FRAC * credit * qty
        sl_total = -1.0 * max_risk * qty
        return ManagedPosition(
            regime="credit_spread",
            qty=qty,
            position=body,
            tp_total=tp_total,
            sl_total=sl_total,
            time_stop_days=R4_TIME_STOP_DAYS,
        )

    def _regime_stats(self, trades: list[ClosedTrade], regime: Regime) -> dict[str, Any]:
        sub = [t for t in trades if t.regime == regime]
        n = len(sub)
        if n == 0:
            return {"n": 0, "win_rate": 0.0, "avg_pnl": 0.0}
        wins = sum(1 for t in sub if t.pnl_usd > 0)
        return {
            "n": n,
            "win_rate": wins / n,
            "avg_pnl": float(sum(t.pnl_usd for t in sub) / n),
        }

    def metrics(self) -> dict[str, Any]:
        trades = self.trade_log
        n = len(trades)
        end_cap = self.current_capital
        sdf = self._spy_df
        t0, t1 = sdf.index.min(), sdf.index.max()
        years = max((t1 - t0).days / 365.25, 1e-9)
        total_return = (end_cap / self.initial_capital) - 1.0 if self.initial_capital else float("nan")
        if self.initial_capital > 0 and end_cap > 0:
            cagr = (end_cap / self.initial_capital) ** (1.0 / years) - 1.0
        else:
            cagr = float("nan")

        peak = self.initial_capital
        max_dd = 0.0
        for _, cap in self._equity_curve:
            peak = max(peak, cap)
            if peak > 0:
                max_dd = max(max_dd, (peak - cap) / peak)

        base: dict[str, Any] = {
            "total_trades": n,
            "win_rate": (sum(1 for t in trades if t.pnl_usd > 0) / n) if n else 0.0,
            "starting_capital": self.initial_capital,
            "ending_capital": end_cap,
            "total_return": total_return,
            "cagr": cagr,
            "max_drawdown": max_dd,
            "dd_risk_scaling": bool(self.dd_risk_scaling),
            "macro_overlay_enabled": bool(self.macro_overlay_enabled),
            "macro_overlay_pnl_usd": float(self._macro_overlay_pnl_usd),
            "macro_overlay_total_frac": float(self.macro_overlay_total_frac),
        }

        for r in (
            "pmcc",
            "diagonal",
            "r2_spread",
            "naked",
            "credit_spread",
        ):
            st = self._regime_stats(trades, r)
            base[f"{r}_trades"] = st["n"]
            base[f"{r}_win_rate"] = st["win_rate"]
            base[f"{r}_avg_pnl"] = st["avg_pnl"]

        return base

    def print_metrics(self) -> None:
        m = self.metrics()
        dd_note = ""
        if m.get("dd_risk_scaling"):
            dd_note = (
                f" | DD sizing: enter≥{self._dd_scale_enter:.0%} → ×{self._dd_scale_mult:.2f}, "
                f"exit≤{self._dd_scale_exit:.0%}"
            )
        overlap_note = ""
        if self.overlap_portfolio:
            vix_scale_tag = " +VIX-scale" if self.r2_vix_scale_contracts else ""
            overlap_note = (
                f" | overlap_portfolio=ON slice={self.overlap_slice_contracts}{vix_scale_tag} "
                "(daily add per sleeve within legacy risk cap)"
            )
        filter_notes: list[str] = []
        if self.disable_pmcc:
            filter_notes.append("PMCC/R1-disabled")
        if self.r2_term_structure_gate:
            filter_notes.append("R2-term-struct-gate")
        filter_note = (" | " + " ".join(filter_notes)) if filter_notes else ""
        mode_note = ""
        if self.portfolio_mode != "full":
            mode_note = f" | portfolio_mode={self.portfolio_mode} (isolated benchmark)"
        macro_note = ""
        if self.macro_overlay_enabled:
            macro_note = (
                f" | macro_overlay frac={self.macro_overlay_total_frac:.2%} "
                f"cumulative ${m.get('macro_overlay_pnl_usd', 0.0):,.2f}"
            )
        print(
            f"4-Regime VRP | SPY > SMA(200) required for new trades | base risk sleeves{dd_note}{overlap_note}{filter_note}{mode_note}{macro_note}"
        )
        print("  --- Overall ---")
        print(f"  Starting capital ($): {m['starting_capital']:,.2f}")
        print(f"  Ending capital ($):   {m['ending_capital']:,.2f}")
        print(f"  Total return:         {m['total_return']:.2%}")
        print(f"  Max drawdown:         {m['max_drawdown']:.2%}")
        _cagr = m["cagr"]
        print(
            "  CAGR (full window):   "
            + (f"{_cagr:.2%}" if isinstance(_cagr, float) and math.isfinite(_cagr) else "n/a")
        )
        print(f"  Total trades:         {m['total_trades']}")
        print("  --- By regime (R1–R4 + R2b) ---")
        for label, key in [
            ("R1 Weekly strangle (VIX<12)", "pmcc"),
            ("R2a Put diagonal (12–20)", "diagonal"),
            ("R2b Put spread (12–20)", "r2_spread"),
            ("R3 Put spread (20–30)", "naked"),
            ("R4 Credit spread (VIX>30)", "credit_spread"),
        ]:
            print(
                f"  {label}: {m[key + '_trades']} trades, "
                f"WR {m[key + '_win_rate']:.2%}, "
                f"avg ${m[key + '_avg_pnl']:,.2f}"
            )


def parquet_file_date_bounds(parquet_path: str | Path) -> tuple[pd.Timestamp, pd.Timestamp]:
    """Min/max ``date`` in the options Parquet (row-group scan; needs pyarrow)."""
    try:
        import pyarrow.parquet as pq
    except ImportError as e:
        raise ImportError("pyarrow required for Parquet date bounds") from e
    path = Path(parquet_path).expanduser()
    pf = pq.ParquetFile(path)
    dmin: pd.Timestamp | None = None
    dmax: pd.Timestamp | None = None
    for i in range(pf.num_row_groups):
        col = pf.read_row_group(i, columns=["date"]).column(0)
        s = pd.to_datetime(col.to_pandas(), errors="coerce").dt.normalize()
        s = s.dropna()
        if s.empty:
            continue
        lo, hi = s.min(), s.max()
        dmin = lo if dmin is None else min(dmin, lo)
        dmax = hi if dmax is None else max(dmax, hi)
    if dmin is None or dmax is None:
        raise ValueError(f"No dates column data in {path}")
    return pd.Timestamp(dmin), pd.Timestamp(dmax)


def trading_days_intersecting_spy(
    loader: Union[IVolatilityLoader, ThetaChunksLoader, SyntheticLoader],
    spy_index: pd.Index,
    start: pd.Timestamp,
    end: pd.Timestamp,
) -> list[pd.Timestamp]:
    """Chain dates from the loader that fall in [start, end] and exist on ``spy_index``."""
    start_n, end_n = _norm_day(start), _norm_day(end)
    out: list[pd.Timestamp] = []
    for d in loader.iter_chain_dates():
        dn = _norm_day(d)
        if start_n <= dn <= end_n and dn in spy_index:
            out.append(dn)
    return sorted(out)


def standalone_max_drawdown_from_pnls(pnls: list[float], initial: float) -> float:
    """Max drawdown on equity = initial + cumulative PnL (chronological trade stream)."""
    eq = float(initial)
    peak = eq
    mdd = 0.0
    for p in pnls:
        eq += p
        peak = max(peak, eq)
        if peak > 0:
            mdd = max(mdd, (peak - eq) / peak)
    return float(mdd)


def extended_regime_metrics(trades: list[ClosedTrade], initial_capital: float) -> dict[str, Any]:
    """
    Research metrics per regime: median, total PnL, standalone max DD on regime-only PnL stream,
    downside share of portfolio losses, profit factor.
    """
    regimes: tuple[Regime, ...] = ("pmcc", "diagonal", "r2_spread", "naked", "credit_spread")
    all_pnls = [float(t.pnl_usd) for t in trades]
    total_loss = sum(p for p in all_pnls if p < 0)
    out: dict[str, Any] = {}
    for r in regimes:
        sub = [t for t in trades if t.regime == r]
        pnls = [float(t.pnl_usd) for t in sub]
        n = len(pnls)
        if n == 0:
            out[r] = {
                "n": 0,
                "win_rate": 0.0,
                "avg_pnl": 0.0,
                "median_pnl": 0.0,
                "total_pnl": 0.0,
                "std_pnl": 0.0,
                "worst": None,
                "best": None,
                "sum_negative_pnl": 0.0,
                "downside_share_of_losses": 0.0,
                "standalone_max_dd": 0.0,
                "profit_factor": float("nan"),
            }
            continue
        wins = sum(1 for p in pnls if p > 0)
        neg_sum = sum(p for p in pnls if p < 0)
        pos_sum = sum(p for p in pnls if p > 0)
        chron = sorted(sub, key=lambda t: t.exit_date)
        pnls_chron = [float(t.pnl_usd) for t in chron]
        standalone_dd = standalone_max_drawdown_from_pnls(pnls_chron, initial_capital)
        pf = pos_sum / abs(neg_sum) if neg_sum < 0 else float("inf")
        downside_share = (neg_sum / total_loss) if total_loss < 0 else 0.0
        out[r] = {
            "n": n,
            "win_rate": wins / n,
            "avg_pnl": float(statistics.mean(pnls)),
            "median_pnl": float(statistics.median(pnls)),
            "total_pnl": float(sum(pnls)),
            "std_pnl": float(statistics.pstdev(pnls)) if n > 1 else 0.0,
            "worst": float(min(pnls)),
            "best": float(max(pnls)),
            "sum_negative_pnl": float(neg_sum),
            "downside_share_of_losses": float(downside_share),
            "standalone_max_dd": float(standalone_dd),
            "profit_factor": float(pf),
        }
    return out


def r1_structure_digest() -> dict[str, Any]:
    """Static R1 (weekly long strangle) parameters for sanity checks vs the design doc."""
    return {
        "regime_name": "R1 weekly long strangle (same expiry)",
        "vix_band_strict": f"VIX < {VIX_R1_MAX}",
        "target_dte": R1_STRANGLE_DTE,
        "call_target_delta": R1_CALL_DELTA,
        "put_target_delta": R1_PUT_DELTA,
        "legs": "buy call + buy put (put strike < call strike)",
        "time_stop_days": R1_TIME_STOP_DAYS,
        "take_profit_frac_of_debit": R1_TP_FRAC,
        "stop_loss_frac_of_debit": R1_SL_FRAC,
    }


# --- Pitch deck HTML (vrp_fund_pitch.html) ---------------------------------

DEFAULT_PITCH_HTML_PATH = _REPO_ROOT / "vrp_fund_pitch.html"

_LEDGER_BLOCK = re.compile(
    r"(    // VRP_LEDGER_DATA_BEGIN\n)(.*?)(\n    // VRP_LEDGER_DATA_END)",
    re.DOTALL,
)


def _exit_mark_usd(initial_net: float, pnl: float) -> float:
    """Implied closing mark (debit to close for credit; value recovered for debit)."""
    if initial_net >= 0.0:
        return float(initial_net - pnl)
    return float(pnl - initial_net)


def _structure_label_for_regime(regime: Regime) -> str:
    if regime == "pmcc":
        return (
            f"Weekly long strangle · ~{R1_STRANGLE_DTE}D buy ~{R1_CALL_DELTA:.0%}Δ C / "
            f"buy ~{abs(R1_PUT_DELTA):.0%}Δ P (R1)"
        )
    if regime == "diagonal":
        return (
            f"R2a: short {R2_DIAG_LEG_SPECS[0][0]}D -{abs(R2_DIAG_LEG_SPECS[0][1]):.0%}Δ P / "
            f"long {R2_DIAG_LEG_SPECS[1][0]}D -{abs(R2_DIAG_LEG_SPECS[1][1]):.0%}Δ P · put diagonal"
        )
    if regime == "r2_spread":
        return (
            f"R2b: short {R2_CREDIT_LEG_SPECS[0][0]}D -{abs(R2_CREDIT_LEG_SPECS[0][1]):.0%}Δ P / "
            f"long same-exp -{abs(R2_CREDIT_LEG_SPECS[1][1]):.0%}Δ P · put credit spread"
        )
    if regime == "naked":
        return "Short 45D -15Δ P / Long 45D -7Δ P · put credit spread (R3)"
    if regime == "credit_spread":
        return "Short 45D -15Δ P / Long 45D -5Δ P · put credit spread"
    return "Unknown structure"


def _regime_code_and_label(regime: Regime) -> tuple[int, str]:
    if regime == "pmcc":
        return 1, "R1 — Weekly strangle"
    if regime == "diagonal":
        return 2, "R2a — Put diagonal"
    if regime == "r2_spread":
        return 5, "R2b — Put spread"
    if regime == "naked":
        return 3, "R3 — Put spread"
    if regime == "credit_spread":
        return 4, "R4 — Wide PCS"
    return 0, "Unknown"


def closed_trade_to_dashboard_row(trade: ClosedTrade) -> dict[str, Any]:
    """Shape matches `tradeHistoryData` objects in vrp_fund_pitch.html."""
    code, rlabel = _regime_code_and_label(trade.regime)
    entry = float(trade.initial_net_premium)
    pnl = float(trade.pnl_usd)
    mrg = float(trade.max_margin)
    ror = (pnl / mrg) * 100.0 if mrg > 1e-9 else 0.0
    return {
        "entryDate": _norm_day(trade.entry_date).strftime("%Y-%m-%d"),
        "exitDate": _norm_day(trade.exit_date).strftime("%Y-%m-%d"),
        "regime": code,
        "regimeLabel": rlabel,
        "structure": _structure_label_for_regime(trade.regime),
        "entryMark": round(entry, 2),
        "exitMark": round(_exit_mark_usd(entry, pnl), 2),
        "netPnl": round(pnl, 2),
        "returnOnRisk": round(ror, 2),
        "daysHeld": int(trade.days_in_trade),
    }


def export_trades_to_html(
    trade_log: list[ClosedTrade],
    html_file_path: str | Path = DEFAULT_PITCH_HTML_PATH,
) -> Path:
    """
    Serialize ``trade_log`` into JSON and inject it into ``vrp_fund_pitch.html`` between
    ``VRP_LEDGER_DATA_BEGIN`` / ``VRP_LEDGER_DATA_END``, replacing the placeholder
    ``const tradeHistoryData = [];``.
    """
    path = Path(html_file_path)
    rows = [closed_trade_to_dashboard_row(t) for t in trade_log]
    rows.sort(key=lambda r: r["entryDate"])
    js_array = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
    inner = f"    const tradeHistoryData = {js_array};\n"

    text = path.read_text(encoding="utf-8")
    m = _LEDGER_BLOCK.search(text)
    if not m:
        raise ValueError(
            f"Could not find VRP_LEDGER_DATA_BEGIN/END markers in {path}. "
            "Regenerate vrp_fund_pitch.html or add the markers."
        )
    # Preserve everything after the END marker (ledger render + Chart.js + </script></body></html>).
    new_text = text[: m.start()] + m.group(1) + inner + m.group(3) + text[m.end() :]
    path.write_text(new_text, encoding="utf-8")
    verify = path.read_text(encoding="utf-8")
    if "</html>" not in verify or "function formatUsd" not in verify:
        raise RuntimeError(
            f"export_trades_to_html wrote {path} but the file no longer contains the pitch "
            "deck tail (</html> / formatUsd). Restore vrp_fund_pitch.html from git or "
            "vrp_fund_pitch_script_tail.inc."
        )
    return path


if __name__ == "__main__":
    _pq = DEFAULT_IV_PARQUET_PATH
    _bt: VRPBacktester
    _sleeve_cfg: dict[Regime, float] | None = None
    _overlay_fracs: dict[str, float] | None = None
    _overlay_cap: float | None = None
    _total_cap: float | None = None
    if DEFAULT_SLEEVE_RISK_JSON_PATH.is_file():
        _cfg = load_strategy_config_file(DEFAULT_SLEEVE_RISK_JSON_PATH)
        apply_strategy_params_to_vrp_backtester_module(_cfg.strategy_params)
        _sleeve_cfg = cast(dict[Regime, float], dict(_cfg.sleeve_risk_fractions))
        _overlay_fracs = dict(_cfg.overlay_risk_fractions) if _cfg.overlay_risk_fractions else None
        _overlay_cap = _cfg.overlay_risk_cap_frac
        _total_cap = _cfg.total_risk_cap_frac
        print(f"  Parity config: {DEFAULT_SLEEVE_RISK_JSON_PATH} (sleeves + strategy_params)")
    if _pq.is_file():
        _ld_iv = IVolatilityLoader(_pq)
        _d0, _d1 = parquet_file_date_bounds(_pq)
        _yf_start = (_d0 - pd.Timedelta(days=400)).strftime("%Y-%m-%d")
        _yf_end = (_d1 + pd.Timedelta(days=5)).strftime("%Y-%m-%d")
        _spy_wide = load_spy_vix_from_yfinance(_yf_start, _yf_end)
        _days = trading_days_intersecting_spy(_ld_iv, _spy_wide.index, _d0, _d1)
        if len(_days) == 0:
            warnings.warn(
                "No intersecting Parquet/yfinance dates; "
                "falling back to every chain date present in spy_df.",
                stacklevel=2,
            )
            _in_spy = set(_spy_wide.index)
            _days = sorted(
                {_norm_day(d) for d in _ld_iv.iter_chain_dates() if _norm_day(d) in _in_spy}
            )
        print(f"  IVolatilityLoader Parquet: {_pq}")
        print(
            f"  Continuous window: {_d0.date()} → {_d1.date()} "
            f"({len(_days)} chain days with SPY/VIX)"
        )
        _bt = VRPBacktester(
            _ld_iv,
            initial_capital=DEFAULT_STARTING_CAPITAL,
            spy_df=_spy_wide,
            sleeve_risk_fractions=_sleeve_cfg,
            overlay_risk_fractions=_overlay_fracs,
            overlay_risk_cap_frac=_overlay_cap,
            total_risk_cap_frac=_total_cap,
        )
        _bt.run_backtest(trading_days=_days)
    else:
        warnings.warn(
            f"No Parquet at {_pq} — run combine_csvs_to_parquet on iVolatility CSVs. "
            "Falling back to SyntheticLoader over the default yfinance window.",
            stacklevel=2,
        )
        _spy_panel = load_spy_vix_from_yfinance(DEFAULT_YF_START, DEFAULT_YF_END)
        _ld = SyntheticLoader(_spy_panel)
        _bt = VRPBacktester(
            _ld,
            initial_capital=DEFAULT_STARTING_CAPITAL,
            spy_df=_spy_panel,
            sleeve_risk_fractions=_sleeve_cfg,
            overlay_risk_fractions=_overlay_fracs,
            overlay_risk_cap_frac=_overlay_cap,
            total_risk_cap_frac=_total_cap,
        )
        _bt.run_backtest()
    _bt.print_metrics()
    _out = export_trades_to_html(_bt.trade_log, DEFAULT_PITCH_HTML_PATH)
    print(f"  Trade ledger written: {_out} ({len(_bt.trade_log)} rows)")
