#!/usr/bin/env python3
"""
Walk-forward Markov-chain backtest on **VIX futures: M1 / M2 / M3**.

Data: ``RenTech/data/vix_futures_cboe.parquet``

Strategy
--------
Each of VX1, VX2, VX3 gets a per-contract Markov state machine on its rolling
252-day price percentile.  Monte Carlo (21-day horizon) produces a calibrated
probability of "bullish" resolution.

**Direction:**
  - ``edge = calibrated_prob - current_percentile``
  - edge > +threshold  → LONG  (Kelly-sized)
  - edge < -threshold  → SHORT (Kelly-sized, negative weight)
  - |edge| <= threshold → PASS (cash)

**Relative mispricing overlay (--rel-spread):**
  After per-contract signals, compare edges across the three tenors.  If the
  edge spread between the richest and cheapest contract exceeds ``--rel-threshold``,
  an additional calendar spread is opened: long the underpriced contract / short the
  overpriced contract.  This is additive and still subject to gross-exposure cap.

**Risk controls:**
  - ``--max-short``    : max abs weight per contract when short (default 0.50)
  - ``--gross-cap``    : max sum(|weights|) across all contracts (default 1.50)
  - Net short on any contract is capped at ``--max-short``.
  - Residual weight earns cash yield.

Benchmarks:
  * **markov_vx** — Markov long/short dynamic
  * **long_only_markov** — same signals, longs only
  * **static_vx** — always long 1/3 each VX1+VX2+VX3

**Execution modes:**
  - ``--fixed-expiry`` (default): locks specific contract ``expiry`` at entry per
    VX1/VX2/VX3 slot; rolls explicitly on expiry via ``vix_fixed_outright_engine``.
  - ``--no-fixed-expiry``: constant-tenor rolled series (research only — can
    overstate returns across contract rolls).

Benchmarks (static / long-only) remain on constant-tenor series for reference.

Example::

    cd /Users/robzingale/trading_bot
    .venv/bin/python RenTech/data_pipeline/download_cboe_vix_futures.py
    PYTHONUNBUFFERED=1 .venv/bin/python \\
        RenTech/strategy_stack/run_markov_vix_futures_backtest.py \\
        --start 2016-01-04 --rel-spread --fixed-expiry
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

import numpy as np
import pandas as pd

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

from RenTech.strategy_stack.markov_chain_trading import (
    MarkovChainTradingModel,
    markov_exposure_decision,
    walk_forward_exposure_series,
    walk_forward_markov_features,
)

from RenTech.strategy_stack.vix_fixed_calendar_engine import VixContractStore
from RenTech.strategy_stack.vix_fixed_outright_engine import simulate_fixed_outright_portfolio

DATA_DIR = _REPO / "RenTech" / "data"
LOGS = DATA_DIR / "logs"
VIX_FUT_PATH = DATA_DIR / "vix_futures_cboe.parquet"
CONTRACTS_PATH = DATA_DIR / "vix_futures_contracts_long.parquet"
DEFAULT_OUT_PREFIX = LOGS / "markov_vix_futures_ls"

# Equal risk budget per contract
VX_CONTRACTS = ["VX1", "VX2", "VX3"]
VX_BUDGET = 1.0 / len(VX_CONTRACTS)


def _ensure_vix_futures_panel(force_download: bool = False) -> pd.DataFrame:
    if force_download or not VIX_FUT_PATH.is_file():
        print(f"Building {VIX_FUT_PATH} …", flush=True)
        script = _REPO / "RenTech" / "data_pipeline" / "download_cboe_vix_futures.py"
        subprocess.run([sys.executable, str(script)], cwd=str(_REPO), check=True)
    panel = pd.read_parquet(VIX_FUT_PATH)
    panel.index = pd.to_datetime(panel.index).tz_localize(None).sort_values()
    return panel


def _prepare_futures_dict(panel: pd.DataFrame) -> dict[str, pd.DataFrame]:
    """VX1 / VX2 / VX3 price + daily returns, settle preferred over close."""
    out: dict[str, pd.DataFrame] = {}
    for n, ticker in enumerate(VX_CONTRACTS, start=1):
        col = f"vx{n}"
        settle = panel.get(f"{col}_settle")
        close_col = panel.get(f"{col}_close")
        if settle is not None and close_col is not None:
            price = settle.fillna(close_col).astype(np.float64)
        elif col in panel.columns:
            price = panel[col].astype(np.float64)
        elif settle is not None:
            price = settle.astype(np.float64)
        else:
            # VX3 may only have settle; gracefully skip if missing
            print(f"  Warning: no price data for {ticker}, skipping", flush=True)
            continue
        df = pd.DataFrame(index=panel.index)
        df["close"] = price
        df["ret"] = df["close"].pct_change()
        out[ticker] = df
    return out


def _metrics(daily_ret: pd.Series, capital: float) -> dict[str, float]:
    r = daily_ret.dropna().astype(np.float64)
    n = len(r)
    if n == 0:
        return {
            "total_return_pct": float("nan"),
            "cagr_pct": float("nan"),
            "sharpe": float("nan"),
            "max_drawdown_pct": float("nan"),
            "ending_equity_usd": capital,
            "n_days": 0,
        }
    eq = capital * (1.0 + r).cumprod()
    years = n / 252.0
    end_eq = float(eq.iloc[-1])
    total_ret = end_eq / capital - 1.0
    cagr = (end_eq / capital) ** (1.0 / years) - 1.0 if years > 0 else float("nan")
    dd = eq / eq.cummax() - 1.0
    sd = float(r.std(ddof=1)) if n > 1 else float("nan")
    sharpe = float(r.mean() / sd * np.sqrt(252.0)) if sd > 1e-12 else float("nan")
    return {
        "total_return_pct": float(total_ret * 100.0),
        "cagr_pct": float(cagr * 100.0),
        "sharpe": sharpe,
        "max_drawdown_pct": float(dd.min() * 100.0),
        "ending_equity_usd": end_eq,
        "n_days": n,
    }


def _apply_relative_spread(
    weight_df: pd.DataFrame,
    edge_df: pd.DataFrame,
    *,
    rel_threshold: float,
    budget: float,
    max_short: float,
    gross_cap: float,
    long_only: bool = False,
) -> pd.DataFrame:
    """
    Overlay calendar spread positions where edge spread across tenors > rel_threshold.

    Long the most underpriced contract, short the most overpriced, equal-weight
    at half the budget each.  Applied AFTER per-contract signals; gross cap enforced.
    """
    w = weight_df.copy()
    cols = [c for c in VX_CONTRACTS if c in edge_df.columns and c in w.columns]
    if len(cols) < 2:
        return w

    for dt in w.index:
        edges = {c: float(edge_df.loc[dt, c]) for c in cols if np.isfinite(edge_df.loc[dt, c])}
        if len(edges) < 2:
            continue
        best = max(edges, key=lambda k: edges[k])
        worst = min(edges, key=lambda k: edges[k])
        if best == worst:
            continue
        spread = edges[best] - edges[worst]
        if spread < rel_threshold:
            continue
        # Spread size = half the per-contract budget
        sz = budget * 0.5
        w.loc[dt, best] = w.loc[dt, best] + sz
        if not long_only:
            w.loc[dt, worst] = max(-max_short, w.loc[dt, worst] - sz)

    # Re-enforce gross cap row-by-row
    gross = w[cols].abs().sum(axis=1)
    over = gross > gross_cap
    if over.any():
        scale = (gross_cap / gross).clip(upper=1.0)
        w.loc[over, cols] = w.loc[over, cols].mul(scale[over], axis=0)

    return w


    return w


def build_markov_vix_feature_cache(
    futures_dict: dict[str, pd.DataFrame],
    *,
    model: MarkovChainTradingModel,
    percentile_window: int,
    lookback: int,
    matrix_refresh: int,
    vix: pd.Series | None = None,
    vix_lo: float = 15.0,
    vix_hi: float = 25.0,
) -> tuple[pd.DatetimeIndex, dict[str, pd.DataFrame], list[str]]:
    """Precompute Markov features per contract for fast parameter sweeps."""
    contracts = [t for t in VX_CONTRACTS if t in futures_dict]
    master = futures_dict[contracts[0]].index.sort_values()
    for t in contracts[1:]:
        master = master.union(futures_dict[t].index)
    master = master.sort_values()

    vix_aligned = vix.reindex(master).ffill() if vix is not None else None
    features: dict[str, pd.DataFrame] = {}
    for t in contracts:
        close = futures_dict[t]["close"].reindex(master).ffill()
        feat = walk_forward_markov_features(
            close,
            percentile_window=percentile_window,
            lookback=lookback,
            model=model,
            matrix_refresh=matrix_refresh,
            vix=vix_aligned,
            vix_lo=vix_lo,
            vix_hi=vix_hi,
        )
        features[t] = feat.reindex(master)
    return master, features, contracts


def assemble_vix_weights_from_features(
    master: pd.DatetimeIndex,
    features: dict[str, pd.DataFrame],
    contracts: list[str],
    *,
    edge_threshold: float,
    kelly_mult: float,
    allow_short: bool,
    max_short: float,
    gross_cap: float,
    rel_spread: bool,
    rel_threshold: float,
    long_only: bool = False,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    """Apply trading rules to cached Markov features."""
    raw_weight_df = pd.DataFrame(0.0, index=master, columns=contracts, dtype=np.float64)
    edge_df = pd.DataFrame(np.nan, index=master, columns=contracts, dtype=np.float64)
    decision_df = pd.DataFrame("PASS", index=master, columns=contracts)

    for t in contracts:
        feat = features[t].reindex(master)
        cal = feat["calibrated_prob"].astype(np.float64).values
        mp = np.clip(feat["percentile"].astype(np.float64).values, 0.0, 1.0)
        edge_arr = feat["edge"].astype(np.float64).values
        edge_df[t] = edge_arr

        exp_out = np.zeros(len(master), dtype=np.float64)
        dec_out = np.full(len(master), "PASS", dtype=object)
        for j in range(len(master)):
            if not np.isfinite(edge_arr[j]):
                continue
            exp, dec = markov_exposure_decision(
                float(cal[j]),
                float(mp[j]),
                float(edge_arr[j]),
                edge_threshold=edge_threshold,
                kelly_mult=kelly_mult,
                allow_short=allow_short,
                max_short_exposure=max_short,
            )
            exp_out[j] = exp * VX_BUDGET
            dec_out[j] = dec
        raw_weight_df[t] = exp_out
        decision_df[t] = dec_out

    for t in contracts:
        raw_weight_df[t] = raw_weight_df[t].clip(lower=-max_short * VX_BUDGET, upper=VX_BUDGET)

    if rel_spread and len(contracts) >= 2:
        raw_weight_df = _apply_relative_spread(
            raw_weight_df,
            edge_df,
            rel_threshold=rel_threshold,
            budget=VX_BUDGET,
            max_short=max_short * VX_BUDGET,
            gross_cap=gross_cap,
            long_only=long_only or not allow_short,
        )

    gross = raw_weight_df[contracts].abs().sum(axis=1)
    over = gross > gross_cap
    if over.any():
        scale = (gross_cap / gross).clip(upper=1.0)
        raw_weight_df.loc[over, contracts] = raw_weight_df.loc[over, contracts].mul(
            scale[over], axis=0
        )
    return raw_weight_df, edge_df, decision_df


def build_markov_vix_signals(
    futures_dict: dict[str, pd.DataFrame],
    *,
    model: MarkovChainTradingModel,
    percentile_window: int,
    lookback: int,
    matrix_refresh: int,
    allow_short: bool,
    max_short: float,
    gross_cap: float,
    rel_spread: bool,
    rel_threshold: float,
    vix: pd.Series | None = None,
    vix_lo: float = 15.0,
    vix_hi: float = 25.0,
    long_only: bool = False,
) -> tuple[pd.DatetimeIndex, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, list[str]]:
    """Return master index, returns, weights, edges, probs, decisions, contract list."""
    contracts = [t for t in VX_CONTRACTS if t in futures_dict]
    master = futures_dict[contracts[0]].index.sort_values()
    for t in contracts[1:]:
        master = master.union(futures_dict[t].index)
    master = master.sort_values()

    ret_df = pd.DataFrame(index=master, dtype=np.float64)
    raw_weight_df = pd.DataFrame(0.0, index=master, columns=contracts, dtype=np.float64)
    edge_df = pd.DataFrame(np.nan, index=master, columns=contracts, dtype=np.float64)
    prob_df = pd.DataFrame(np.nan, index=master, columns=contracts, dtype=np.float64)
    decision_df = pd.DataFrame("PASS", index=master, columns=contracts)

    vix_aligned = vix.reindex(master).ffill() if vix is not None else None

    for t in contracts:
        df = futures_dict[t]
        close = df["close"].reindex(master).ffill()
        ret_df[t] = df["ret"].reindex(master).fillna(0.0)

        sig = walk_forward_exposure_series(
            close,
            percentile_window=percentile_window,
            lookback=lookback,
            model=model,
            matrix_refresh=matrix_refresh,
            vix=vix_aligned,
            vix_lo=vix_lo,
            vix_hi=vix_hi,
            allow_short=allow_short,
            max_short_exposure=max_short,
        )
        if sig.empty:
            continue
        raw_weight_df[t] = (sig["exposure"] * VX_BUDGET).reindex(master).fillna(0.0)
        edge_df[t] = sig["edge"].reindex(master)
        prob_df[t] = sig["calibrated_prob"].reindex(master)
        decision_df[t] = sig["decision"].reindex(master).fillna("PASS")

    for t in contracts:
        raw_weight_df[t] = raw_weight_df[t].clip(lower=-max_short * VX_BUDGET, upper=VX_BUDGET)

    if rel_spread and len(contracts) >= 2:
        raw_weight_df = _apply_relative_spread(
            raw_weight_df,
            edge_df,
            rel_threshold=rel_threshold,
            budget=VX_BUDGET,
            max_short=max_short * VX_BUDGET,
            gross_cap=gross_cap,
            long_only=long_only or not allow_short,
        )

    gross = raw_weight_df[contracts].abs().sum(axis=1)
    over = gross > gross_cap
    if over.any():
        scale = (gross_cap / gross).clip(upper=1.0)
        raw_weight_df.loc[over, contracts] = raw_weight_df.loc[over, contracts].mul(
            scale[over], axis=0
        )

    return master, ret_df, raw_weight_df, edge_df, prob_df, decision_df, contracts


def build_markov_vix_ls_portfolio(
    futures_dict: dict[str, pd.DataFrame],
    *,
    model: MarkovChainTradingModel,
    percentile_window: int,
    lookback: int,
    cash_annual_yield: float,
    matrix_refresh: int,
    allow_short: bool,
    max_short: float,
    gross_cap: float,
    rel_spread: bool,
    rel_threshold: float,
    vix: pd.Series | None = None,
    vix_lo: float = 15.0,
    vix_hi: float = 25.0,
) -> pd.DataFrame:
    master, ret_df, raw_weight_df, edge_df, prob_df, decision_df, contracts = build_markov_vix_signals(
        futures_dict,
        model=model,
        percentile_window=percentile_window,
        lookback=lookback,
        matrix_refresh=matrix_refresh,
        allow_short=allow_short,
        max_short=max_short,
        gross_cap=gross_cap,
        rel_spread=rel_spread,
        rel_threshold=rel_threshold,
        vix=vix,
        vix_lo=vix_lo,
        vix_hi=vix_hi,
    )

    gross_final = raw_weight_df[contracts].abs().sum(axis=1)
    cash_weight = (1.0 - gross_final).clip(lower=0.0)
    daily_rf = cash_annual_yield / 252.0

    portfolio_ret = (ret_df[contracts] * raw_weight_df[contracts]).sum(axis=1) + cash_weight * daily_rf

    long_only_w = raw_weight_df[contracts].clip(lower=0.0)
    lo_gross = long_only_w.abs().sum(axis=1)
    lo_cash = (1.0 - lo_gross).clip(lower=0.0)
    long_only_ret = (ret_df[contracts] * long_only_w).sum(axis=1) + lo_cash * daily_rf

    static_w = pd.Series({t: VX_BUDGET for t in contracts})
    static_ret = ret_df[contracts].mul(static_w, axis=1).sum(axis=1)

    out = pd.DataFrame(
        {
            "portfolio_bar_ret": portfolio_ret,
            "long_only_ret": long_only_ret,
            "static_vx_ret": static_ret,
            "cash_weight": cash_weight,
            "gross_exposure": gross_final,
            "execution_mode": "constant_tenor_rolled",
        },
        index=master,
    )
    for t in contracts:
        out[f"weight_{t}"] = raw_weight_df[t]
        out[f"edge_{t}"] = edge_df[t]
        out[f"calibrated_prob_{t}"] = prob_df[t]
        out[f"decision_{t}"] = decision_df[t]
        out[f"close_{t}"] = futures_dict[t]["close"].reindex(master)
    return out


def _build_fixed_expiry_portfolio(
    *,
    store: VixContractStore,
    master: pd.DatetimeIndex,
    raw_weight_df: pd.DataFrame,
    edge_df: pd.DataFrame,
    decision_df: pd.DataFrame,
    prob_df: pd.DataFrame,
    ret_df: pd.DataFrame,
    futures_dict: dict[str, pd.DataFrame],
    contracts: list[str],
    capital: float,
    cash_annual_yield: float,
    gross_cap: float,
    record_trades: bool = True,
) -> tuple[pd.DataFrame, pd.DataFrame]:
    port, trade_log = simulate_fixed_outright_portfolio(
        store=store,
        dates=master.sort_values(),
        target_weights=raw_weight_df[contracts],
        edge_df=edge_df[contracts],
        decision_df=decision_df[contracts],
        contracts=contracts,
        capital=capital,
        cash_annual_yield=cash_annual_yield,
        gross_cap=gross_cap,
        record_trades=record_trades,
    )
    long_only_w = raw_weight_df[contracts].clip(lower=0.0)
    lo_gross = long_only_w.abs().sum(axis=1)
    lo_cash = (1.0 - lo_gross).clip(lower=0.0)
    daily_rf = cash_annual_yield / 252.0
    long_only_ret = (ret_df[contracts] * long_only_w).sum(axis=1) + lo_cash * daily_rf
    static_w = pd.Series({t: VX_BUDGET for t in contracts})
    static_ret = ret_df[contracts].mul(static_w, axis=1).sum(axis=1)

    port["long_only_ret"] = long_only_ret.reindex(port.index).fillna(0.0)
    port["static_vx_ret"] = static_ret.reindex(port.index).fillna(0.0)
    port["execution_mode"] = "fixed_expiry_outright"
    for t in contracts:
        port[f"calibrated_prob_{t}"] = prob_df[t].reindex(port.index)
        port[f"close_{t}"] = futures_dict[t]["close"].reindex(port.index)
    return port, trade_log


def _regime_breakdown(daily: pd.DataFrame, cap: float) -> None:
    """Print per-year return for Markov L/S vs long-only vs static."""
    daily["year"] = pd.to_datetime(daily["date"]).dt.year
    for yr, g in daily.groupby("year"):
        r_ls = float((1 + g["daily_ret_markov"]).prod() - 1)
        r_lo = float((1 + g["long_only_ret"]).prod() - 1)
        r_st = float((1 + g["daily_ret_static_vx"]).prod() - 1)
        print(f"  {yr}  L/S {r_ls:+.1%}   long-only {r_lo:+.1%}   static {r_st:+.1%}")


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--start", default="2016-01-04")
    ap.add_argument("--end", default="")
    ap.add_argument("--capital", type=float, default=100_000.0)
    ap.add_argument("--cash-yield", type=float, default=0.04)
    ap.add_argument("--n-states", type=int, default=10)
    ap.add_argument("--horizon", type=int, default=21)
    ap.add_argument("--n-sims", type=int, default=3000)
    ap.add_argument("--percentile-window", type=int, default=252)
    ap.add_argument("--lookback", type=int, default=252)
    ap.add_argument("--edge-threshold", type=float, default=0.03)
    ap.add_argument(
        "--calibration", choices=("none", "equity_shrink", "polymarket"), default="equity_shrink"
    )
    ap.add_argument("--kelly-mult", type=float, default=0.25)
    ap.add_argument("--matrix-refresh", type=int, default=5)
    ap.add_argument("--max-short", type=float, default=0.50,
                    help="Max abs exposure per contract when short (default 0.50)")
    ap.add_argument("--gross-cap", type=float, default=1.50,
                    help="Max total gross exposure across contracts (default 1.50)")
    ap.add_argument("--rel-spread", action="store_true",
                    help="Add calendar spread overlay using relative mispricing across tenors")
    ap.add_argument("--rel-threshold", type=float, default=0.06,
                    help="Min edge spread to trigger calendar position (default 0.06)")
    ap.add_argument("--vix-regime", action="store_true")
    ap.add_argument("--vix-lo", type=float, default=15.0)
    ap.add_argument("--vix-hi", type=float, default=25.0)
    ap.add_argument("--force-download", action="store_true")
    ap.add_argument(
        "--long-only",
        action="store_true",
        help="Disable short signals (long VX only)",
    )
    ap.add_argument(
        "--fixed-expiry",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Execute on locked contract expiries with explicit rolls (default)",
    )
    ap.add_argument("--out-prefix", type=Path, default=DEFAULT_OUT_PREFIX)
    args = ap.parse_args()

    model = MarkovChainTradingModel(
        n_states=args.n_states,
        horizon=args.horizon,
        n_sims=args.n_sims,
        kelly_mult=args.kelly_mult,
        edge_threshold=args.edge_threshold,
        calibration_mode=args.calibration,
    )

    panel = _ensure_vix_futures_panel(force_download=args.force_download)
    contango = panel.get("contango_ratio_ffill", panel.get("contango_ratio", pd.Series(np.nan, index=panel.index)))
    futures_dict = _prepare_futures_dict(panel)
    if not futures_dict:
        raise RuntimeError("No futures data found in panel. Re-run download_cboe_vix_futures.py.")

    vix_series = panel["vix_spot"].astype(np.float64) if args.vix_regime else None

    contracts_found = list(futures_dict.keys())
    print(f"Contracts loaded: {contracts_found}", flush=True)
    print(
        f"Execution mode: {'fixed-expiry outright' if args.fixed_expiry else 'constant-tenor rolled'}",
        flush=True,
    )
    print("Building Markov VX long/short portfolio …", flush=True)

    trade_log_df: pd.DataFrame | None = None

    allow_short = not args.long_only

    if args.fixed_expiry:
        if not CONTRACTS_PATH.is_file():
            print(f"Building {CONTRACTS_PATH} …", flush=True)
            subprocess.run(
                [sys.executable, str(_REPO / "RenTech/data_pipeline/download_cboe_vix_futures.py")],
                cwd=str(_REPO),
                check=True,
            )
        store = VixContractStore(pd.read_parquet(CONTRACTS_PATH))
        master, ret_df, raw_weight_df, edge_df, prob_df, decision_df, contracts = build_markov_vix_signals(
            futures_dict,
            model=model,
            percentile_window=args.percentile_window,
            lookback=args.lookback,
            matrix_refresh=args.matrix_refresh,
            allow_short=allow_short,
            max_short=args.max_short,
            gross_cap=args.gross_cap,
            rel_spread=args.rel_spread,
            rel_threshold=args.rel_threshold,
            vix=vix_series,
            vix_lo=args.vix_lo,
            vix_hi=args.vix_hi,
            long_only=args.long_only,
        )
        port, trade_log_df = _build_fixed_expiry_portfolio(
            store=store,
            master=master,
            raw_weight_df=raw_weight_df,
            edge_df=edge_df,
            decision_df=decision_df,
            prob_df=prob_df,
            ret_df=ret_df,
            futures_dict=futures_dict,
            contracts=contracts,
            capital=float(args.capital),
            cash_annual_yield=float(args.cash_yield),
            gross_cap=args.gross_cap,
        )
    else:
        port = build_markov_vix_ls_portfolio(
            futures_dict,
            model=model,
            percentile_window=args.percentile_window,
            lookback=args.lookback,
            cash_annual_yield=float(args.cash_yield),
            matrix_refresh=args.matrix_refresh,
            allow_short=allow_short,
            max_short=args.max_short,
            gross_cap=args.gross_cap,
            rel_spread=args.rel_spread,
            rel_threshold=args.rel_threshold,
            vix=vix_series,
            vix_lo=args.vix_lo,
            vix_hi=args.vix_hi,
            long_only=args.long_only,
        )
    port["contango_ratio"] = contango.reindex(port.index).ffill()

    mask = port.index >= pd.Timestamp(args.start)
    if args.end.strip():
        mask &= port.index <= pd.Timestamp(args.end)
    win = port.loc[mask].copy()

    cap = float(args.capital)
    r_ls = win["portfolio_bar_ret"].fillna(0.0).astype(np.float64)
    r_lo = win["long_only_ret"].fillna(0.0).astype(np.float64)
    r_st = win["static_vx_ret"].fillna(0.0).astype(np.float64)

    m_ls = _metrics(r_ls, cap)
    m_lo = _metrics(r_lo, cap)
    m_st = _metrics(r_st, cap)

    # Short-day statistics
    short_cols = [f"weight_{t}" for t in contracts_found if f"weight_{t}" in win.columns]
    short_days = int((win[short_cols].values < -0.01).any(axis=1).sum()) if short_cols else 0
    long_days = int((win[short_cols].values > 0.01).any(axis=1).sum()) if short_cols else 0
    avg_gross = float(win["gross_exposure"].mean())

    prefix = args.out_prefix.expanduser().resolve()
    prefix.parent.mkdir(parents=True, exist_ok=True)
    daily_path = Path(f"{prefix}_daily.csv")
    meta_path = Path(f"{prefix}_meta.json")
    metrics_path = Path(f"{prefix}_metrics.txt")
    signals_path = Path(f"{prefix}_signals.csv")
    trades_path = Path(f"{prefix}_trade_log.csv")

    eq_ls = cap * (1.0 + r_ls).cumprod()
    eq_lo = cap * (1.0 + r_lo).cumprod()

    daily_out_cols: dict = {
        "date": r_ls.index.strftime("%Y-%m-%d"),
        "daily_ret_markov": r_ls.values,
        "long_only_ret": r_lo.values,
        "daily_ret_static_vx": r_st.values,
        "equity_markov_usd": eq_ls.values,
        "equity_long_only_usd": eq_lo.values,
        "gross_exposure": win["gross_exposure"].values,
        "cash_weight": win["cash_weight"].values,
        "contango_ratio": win["contango_ratio"].values,
    }
    for t in contracts_found:
        daily_out_cols[f"close_{t}"] = win.get(f"close_{t}", pd.Series(np.nan, index=win.index)).values
    pd.DataFrame(daily_out_cols).to_csv(daily_path, index=False)

    sig_cols = [c for c in win.columns if c.startswith(("weight_", "edge_", "decision_", "calibrated_prob_"))]
    sig_out = win[sig_cols].copy()
    sig_out.insert(0, "date", sig_out.index.strftime("%Y-%m-%d"))
    sig_out.to_csv(signals_path, index=False)

    if trade_log_df is not None and not trade_log_df.empty:
        t0 = pd.Timestamp(args.start)
        t1 = pd.Timestamp(args.end) if args.end.strip() else None
        ent = pd.to_datetime(trade_log_df["entry_date"])
        trade_log_df = trade_log_df.loc[ent >= t0]
        if t1 is not None:
            trade_log_df = trade_log_df.loc[ent <= t1]
        trade_log_df.to_csv(trades_path, index=False)
        print(f"Wrote {trades_path}  ({len(trade_log_df)} fixed-expiry trades)")

    start_d = str(r_ls.index.min().date()) if len(r_ls) else None
    end_d = str(r_ls.index.max().date()) if len(r_ls) else None

    cmd = (
        f"cd {_REPO} && PYTHONUNBUFFERED=1 .venv/bin/python "
        f"RenTech/strategy_stack/run_markov_vix_futures_backtest.py --start {args.start}"
        f" --max-short {args.max_short} --gross-cap {args.gross_cap}"
    )
    if args.rel_spread:
        cmd += f" --rel-spread --rel-threshold {args.rel_threshold}"
    if args.vix_regime:
        cmd += f" --vix-regime --vix-lo {args.vix_lo} --vix-hi {args.vix_hi}"
    if not args.fixed_expiry:
        cmd += " --no-fixed-expiry"

    exec_mode = (
        str(win["execution_mode"].iloc[0])
        if "execution_mode" in win.columns
        else ("fixed_expiry_outright" if args.fixed_expiry else "constant_tenor_rolled")
    )

    meta = {
        "strategy": "markov_vix_futures_ls_m1_m2_m3",
        "execution_mode": exec_mode,
        "contracts": contracts_found,
        "data_source": str(VIX_FUT_PATH),
        "model": {
            "n_states": args.n_states,
            "horizon": args.horizon,
            "n_sims": args.n_sims,
            "percentile_window": args.percentile_window,
            "lookback": args.lookback,
            "edge_threshold": args.edge_threshold,
            "calibration": args.calibration,
            "kelly_mult": args.kelly_mult,
            "matrix_refresh": args.matrix_refresh,
            "allow_short": allow_short,
            "max_short": args.max_short,
            "gross_cap": args.gross_cap,
            "rel_spread": args.rel_spread,
            "rel_threshold": args.rel_threshold,
        },
        "start": start_d,
        "end": end_d,
        "capital": cap,
        "markov_ls": m_ls,
        "long_only_markov": m_lo,
        "static_equal_vx": m_st,
        "activity": {
            "short_days": short_days,
            "long_days": long_days,
            "avg_gross_exposure": round(avg_gross, 4),
            "n_trades": int(len(trade_log_df)) if trade_log_df is not None else None,
        },
        "artifacts": {
            "daily_csv": str(daily_path),
            "signals_csv": str(signals_path),
            "trade_log_csv": str(trades_path) if trade_log_df is not None else None,
        },
    }
    meta_path.write_text(json.dumps(meta, indent=2) + "\n")

    metrics_path.write_text(
        f"# Markov VIX futures L/S ({start_d} -> {end_d})\n\n"
        f"Contracts: {contracts_found}\n"
        f"Execution: {exec_mode}\n"
        f"Command:\n{cmd}\n\n"
        f"--- Markov L/S (VX1+VX2+VX3) ---\n"
        f"  Total return: {m_ls['total_return_pct']:.2f}%\n"
        f"  CAGR: {m_ls['cagr_pct']:.2f}%\n"
        f"  Sharpe: {m_ls['sharpe']:.3f}\n"
        f"  Max DD: {m_ls['max_drawdown_pct']:.2f}%\n"
        f"  Trades: {len(trade_log_df) if trade_log_df is not None else 'n/a'}\n\n"
        f"--- Long-only Markov (constant-tenor benchmark) ---\n"
        f"  Total return: {m_lo['total_return_pct']:.2f}%\n"
        f"  CAGR: {m_lo['cagr_pct']:.2f}%\n"
        f"  Sharpe: {m_lo['sharpe']:.3f}\n"
        f"  Max DD: {m_lo['max_drawdown_pct']:.2f}%\n\n"
        f"--- Static equal-weight VX (constant-tenor benchmark) ---\n"
        f"  Total return: {m_st['total_return_pct']:.2f}%\n"
        f"  CAGR: {m_st['cagr_pct']:.2f}%\n"
        f"  Sharpe: {m_st['sharpe']:.3f}\n"
        f"  Max DD: {m_st['max_drawdown_pct']:.2f}%\n\n"
        f"Note: prior constant-tenor headline (~+48,566%) was inflated by rolled-tenor accounting.\n"
        f"Activity: short_days={short_days}, long_days={long_days}, "
        f"avg_gross_exposure={avg_gross:.3f}\n\n"
        f"Artifacts:\n  {daily_path}\n  {signals_path}\n  {meta_path}\n"
    )

    print(f"\n# Markov VIX Futures L/S ({start_d} -> {end_d}, {len(r_ls)} days)\n")
    print(f"Contracts: {contracts_found}")
    print(f"Execution: {exec_mode}")
    if args.rel_spread:
        print(f"Relative spread overlay: ON (threshold {args.rel_threshold})")
    print(f"Max short/contract: {args.max_short}  Gross cap: {args.gross_cap}\n")
    print(f"Command:\n{cmd}\n")

    print("--- Markov L/S (dynamic) ---")
    print(f"  Total return: {m_ls['total_return_pct']:.1f}%")
    print(f"  CAGR: {m_ls['cagr_pct']:.2f}%")
    print(f"  Sharpe: {m_ls['sharpe']:.3f}")
    print(f"  Max DD: {m_ls['max_drawdown_pct']:.2f}%")
    print(f"\n--- Long-only Markov ---")
    print(f"  Total return: {m_lo['total_return_pct']:.1f}%")
    print(f"  CAGR: {m_lo['cagr_pct']:.2f}%")
    print(f"  Sharpe: {m_lo['sharpe']:.3f}")
    print(f"  Max DD: {m_lo['max_drawdown_pct']:.2f}%")
    print(f"\n--- Static equal-weight VX1+VX2+VX3 ---")
    print(f"  Total return: {m_st['total_return_pct']:.1f}%")
    print(f"  CAGR: {m_st['cagr_pct']:.2f}%")
    print(f"  Sharpe: {m_st['sharpe']:.3f}")
    print(f"  Max DD: {m_st['max_drawdown_pct']:.2f}%")
    print(f"\nActivity:")
    print(f"  Days with short positions: {short_days}")
    print(f"  Days with long positions:  {long_days}")
    print(f"  Avg gross exposure:        {avg_gross:.3f}x")

    print("\n--- Yearly breakdown ---")
    _regime_breakdown(
        pd.DataFrame(
            {
                "date": r_ls.index.strftime("%Y-%m-%d"),
                "daily_ret_markov": r_ls.values,
                "long_only_ret": r_lo.values,
                "daily_ret_static_vx": r_st.values,
            }
        ),
        cap,
    )

    print(f"\nWrote {daily_path}")
    print(f"Wrote {signals_path}")
    print(f"Wrote {meta_path}")


if __name__ == "__main__":
    main()
