#!/usr/bin/env python3
"""
Run the **4-regime VRP** backtest (:class:`~RenTech.strategy_stack.vrp_backtester.VRPBacktester`)
using **ThetaData 15:45 ET** monthly Parquet chunks from ``build_theta_dataset.py``.

Greeks missing from the Parquet (e.g. ``--skip-greeks``) are filled via Black–Scholes using
SPY close from the same ``spy_df`` as the engine.

Usage::

    python RenTech/strategy_stack/vrp_backtest_theta.py
    python RenTech/strategy_stack/vrp_backtest_theta.py --theta-dir /path/to/theta_chunks
    python RenTech/strategy_stack/vrp_backtest_theta.py --capital 250000
    python RenTech/strategy_stack/vrp_backtest_theta.py --overlap-portfolio --overlap-slice-contracts 1

**Full portfolio (VRP + IV overlays + VXX)** — same model as
``portfolio_vrp_plus_vxx.execute_portfolio_merge``: export VRP JSONL, build overlay/VXX
JSONLs separately (see that module's docstring), then either run
``portfolio_vrp_plus_vxx.py`` or use ``--full-portfolio-report`` (auto-exports VRP trades to logs unless ``--export-trades-jsonl`` is set).
Missing overlay/VXX files are treated as zero PnL; defaults live under ``RenTech/data/logs/``.

**Sizing:** ``--full-portfolio-report`` alone uses **legacy default** overlay/VXX risk budgets
(``$10k`` / ``$10k`` / ``$10k`` / ``$15k`` VXX), which is usually **not** what you want. Pass
``--portfolio-merge-json RenTech/strategy_stack/overlay_risk_fracs_optimized.json`` (output of
``optimize_overlay_risk_fracs.py``) to apply **% allocations** to ``--capital``.

**Step back (no Theta):** run the merged **% allocation** portfolio on existing JSONLs with
``portfolio_vrp_plus_vxx.py --allocation-json RenTech/strategy_stack/overlay_risk_fracs_optimized.json``.

**PUTW-like sleeve (multi-ticker benchmark):** after the VRP run, optionally blend **cash equity**
with an equal-weight multi-root PUTW-like book (see ``benchmark_putw_like_multi_ticker.py``) using
``--putw-like-overlay-frac 0.05`` (5%% notional to that sleeve, 95%% to VRP), both scaled from the
same ``--capital`` starting point.
"""

from __future__ import annotations

import argparse
import json
import math
import sys
from pathlib import Path
from typing import cast

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

import pandas as pd

from RenTech.core.theta_chunks_loader import ThetaChunksLoader, theta_chunks_date_bounds
from RenTech.strategy_stack.vrp_backtester import (
    DEFAULT_STARTING_CAPITAL,
    R2_VIX_MA_TO_MAX_BLOCK_ABOVE,
    ClosedTrade,
    VRPBacktester,
    load_spy_vix_from_yfinance,
    normalize_spy_df,
    trading_days_intersecting_spy,
)
from RenTech.strategy_stack.portfolio_vrp_plus_vxx import merge_capital_from_allocation_json
from RenTech.strategy_stack.vrp_strategy_config import (
    DEFAULT_STRATEGY_CONFIG_PATH,
    apply_strategy_params_to_vrp_backtester_module,
    load_strategy_config_file,
)

_DEFAULT_THETA_DIR = _REPO_ROOT / "RenTech" / "data" / "theta_chunks"


def _vrp_cash_equity_on_trading_days(bt: VRPBacktester, days: list[pd.Timestamp]) -> pd.Series:
    """Last cash equity per session date from ``_equity_curve``, forward-filled over ``days``."""
    pts: dict[pd.Timestamp, float] = {}
    for d, v in bt._equity_curve:
        pts[pd.Timestamp(d).normalize()] = float(v)
    s = pd.Series(pts).sort_index()
    idx = pd.DatetimeIndex([pd.Timestamp(d).normalize() for d in days])
    # ``_equity_curve`` starts at spy_df.index.min(), often before ``days[0]``; reindex(days).ffill()
    # would drop those points and leave NaN — union first, then ffill, then slice.
    union = idx.union(s.index).sort_values()
    out = s.reindex(union).ffill().reindex(idx)
    if bool(out.isna().any()):
        out = out.bfill()
    return out


def _metrics_from_equity_series(eq: pd.Series, initial: float) -> dict[str, float]:
    if eq.empty or initial <= 0:
        return {
            "ending_capital": float("nan"),
            "total_return": float("nan"),
            "cagr": float("nan"),
            "max_drawdown": float("nan"),
        }
    end = float(eq.iloc[-1])
    t0, t1 = eq.index.min(), eq.index.max()
    years = max((t1 - t0).days / 365.25, 1e-9)
    cagr = (end / initial) ** (1.0 / years) - 1.0 if end > 0 else float("nan")
    peak = float(initial)
    max_dd = 0.0
    for v in eq.astype(float):
        peak = max(peak, float(v))
        if peak > 0:
            max_dd = max(max_dd, (peak - float(v)) / peak)
    return {
        "ending_capital": end,
        "total_return": end / initial - 1.0,
        "cagr": float(cagr),
        "max_drawdown": float(max_dd),
    }


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        try:
            sys.stdout.reconfigure(line_buffering=True)
        except (OSError, ValueError):
            pass
    if hasattr(sys.stderr, "reconfigure"):
        try:
            sys.stderr.reconfigure(line_buffering=True)
        except (OSError, ValueError):
            pass

    ap = argparse.ArgumentParser(
        description="4-regime VRP backtest on ThetaData SPY 15:45 Parquet chunks",
    )
    ap.add_argument(
        "--theta-dir",
        type=Path,
        default=_DEFAULT_THETA_DIR,
        help="Directory with spy_1545_YYYY_MM.parquet (default: RenTech/data/theta_chunks)",
    )
    ap.add_argument("--capital", type=float, default=DEFAULT_STARTING_CAPITAL)
    ap.add_argument(
        "--max-days",
        type=int,
        default=0,
        help="If >0, only the first N overlapping trading days (quick smoke test).",
    )
    ap.add_argument(
        "--start",
        type=str,
        default="",
        help="YYYY-MM-DD: drop trading days before this (inclusive).",
    )
    ap.add_argument(
        "--end",
        type=str,
        default="",
        help="YYYY-MM-DD: drop trading days after this (inclusive).",
    )
    ap.add_argument(
        "--no-progress",
        action="store_true",
        help="Disable the tqdm day-by-day progress bar.",
    )
    ap.add_argument(
        "--slippage",
        type=float,
        default=None,
        help="Execution slippage factor (default: engine SLIPPAGE_FACTOR).",
    )
    ap.add_argument(
        "--no-vol-scaling",
        action="store_true",
        help="Disable VVIX / VIX-momentum risk scaling (default: scaling ON).",
    )
    ap.add_argument(
        "--no-r2-crossover-filters",
        action="store_true",
        help="Disable R2 crossover filters (SPY>SMA50, VIX MA20/max50) in the 12–20 VIX band.",
    )
    ap.add_argument(
        "--export-trades-jsonl",
        type=Path,
        default=None,
        help="Write all closed trades to JSONL (for iv_mispricing_complement.py overlay analysis).",
    )
    ap.add_argument(
        "--dd-risk-scaling",
        action="store_true",
        help="Scale down new-entry risk when portfolio drawdown (marked equity vs peak) is high; "
        "see --dd-enter / --dd-exit / --dd-mult.",
    )
    ap.add_argument(
        "--dd-enter",
        type=float,
        default=0.15,
        metavar="FRAC",
        help="Enter reduced-risk mode when drawdown >= this fraction of peak (default: 0.15).",
    )
    ap.add_argument(
        "--dd-exit",
        type=float,
        default=0.10,
        metavar="FRAC",
        help="Exit reduced mode when drawdown <= this fraction (default: 0.10; must be < --dd-enter).",
    )
    ap.add_argument(
        "--dd-mult",
        type=float,
        default=0.50,
        metavar="MULT",
        help="Multiply all new-entry risk by this while in reduced mode (default: 0.5).",
    )
    ap.add_argument(
        "--full-portfolio-report",
        action="store_true",
        help="Merge VRP with IV overlay + VXX JSONLs (auto-export VRP trades if needed).",
    )
    ap.add_argument(
        "--no-portfolio-vxx-sweep",
        action="store_true",
        help="With --full-portfolio-report, skip the VXX allocation sensitivity table.",
    )
    ap.add_argument(
        "--portfolio-merge-json",
        type=Path,
        default=None,
        help="With --full-portfolio-report, load optimize_overlay_risk_fracs.py output and set "
        "put/straddle/RR/VXX risk budgets from fractions × --capital (avoids legacy $10k defaults).",
    )
    ap.add_argument(
        "--strategy-config",
        type=Path,
        default=None,
        help="JSON parity config (default: RenTech/strategy_stack/sleeve_risk_fractions.json if present).",
    )
    ap.add_argument(
        "--overlap-portfolio",
        action="store_true",
        help="Allow overlapping positions: each session add --overlap-slice-contracts per sleeve "
        "within the same risk cap as legacy; each ticket keeps its own TP/SL/time stop.",
    )
    ap.add_argument(
        "--overlap-slice-contracts",
        type=int,
        default=1,
        metavar="N",
        help="Contracts per sleeve per day when --overlap-portfolio (default: 1).",
    )
    ap.add_argument(
        "--no-pmcc",
        action="store_true",
        help="Disable R1 weekly strangle (PMCC) regime entirely; VIX<12 days produce no new entries.",
    )
    ap.add_argument(
        "--r2-vix-scale-contracts",
        action="store_true",
        help="Scale R2 overlap_slice_contracts proportionally to VIX: max(1,round(base*VIX/12)); "
        "deploys more contracts when risk premium is richer (VIX near 20) vs thin (VIX near 12).",
    )
    ap.add_argument(
        "--r2-term-structure-gate",
        action="store_true",
        help="Block new R2 entries when VIX9D > VIX (front-end inverted term structure = near-term "
        "fear spike). Reduces R2 entries that open into short-vol traps.",
    )
    ap.add_argument(
        "--macro-overlay",
        action="store_true",
        help="Add Tier A Yahoo/synthetic daily return sleeve by VIX band (see tier_a_series defaults).",
    )
    ap.add_argument(
        "--macro-overlay-frac",
        type=float,
        default=0.01,
        metavar="F",
        help="Notional weight on mean daily macro return (default: 0.01 = 1%% of capital per day).",
    )
    ap.add_argument(
        "--macro-overlay-no-spy200",
        action="store_true",
        help="Apply macro overlay even when SPY <= SMA(200) (default: require SPY > SMA(200)).",
    )
    ap.add_argument(
        "--macro-overlay-exclude-putw",
        action="store_true",
        help="Build macro panel without PUTW/PBP (longer history; drops putw/jade rows from Yahoo).",
    )
    ap.add_argument(
        "--putw-like-overlay-frac",
        type=float,
        default=0.0,
        metavar="W",
        help="If >0, blend VRP cash equity with equal-weight PUTW-like multi-ticker benchmark: "
        "total = (1-W)×VRP + W×PUTW (same --capital start). Default: 0 (off).",
    )
    ap.add_argument(
        "--putw-like-roots",
        type=str,
        default="SPY,TLT,GLD,IWM,QQQ,USO",
        help="Comma roots for --putw-like-overlay-frac (default: SPY,TLT,GLD,IWM,QQQ,USO).",
    )
    ap.add_argument(
        "--putw-like-dte-target",
        type=int,
        default=30,
        help="DTE target for PUTW-like sleeve (default: 30).",
    )
    ap.add_argument(
        "--putw-like-min-dte",
        type=int,
        default=20,
        help="Min DTE for PUTW-like sleeve (default: 20).",
    )
    ap.add_argument(
        "--putw-like-max-dte",
        type=int,
        default=45,
        help="Max DTE for PUTW-like sleeve (default: 45).",
    )
    args = ap.parse_args()
    if args.portfolio_merge_json is not None and not args.full_portfolio_report:
        print("ERROR: --portfolio-merge-json requires --full-portfolio-report", file=sys.stderr)
        sys.exit(1)

    w_putw = float(args.putw_like_overlay_frac)
    if w_putw < 0.0 or w_putw >= 1.0:
        print("ERROR: --putw-like-overlay-frac must be in [0, 1).", file=sys.stderr)
        sys.exit(1)

    if args.full_portfolio_report and args.export_trades_jsonl is None:
        ts = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")
        auto_out = _REPO_ROOT / "RenTech" / "data" / "logs" / f"vrp_trades_auto_{ts}.jsonl"
        args.export_trades_jsonl = auto_out
        print(f"Auto export enabled for full portfolio merge: {auto_out}")

    merge_frame = None

    theta_dir: Path = args.theta_dir.expanduser()
    if not theta_dir.is_dir():
        print(f"ERROR: --theta-dir is not a directory: {theta_dir}")
        sys.exit(1)

    try:
        d0, d1 = theta_chunks_date_bounds(theta_dir)
    except (OSError, ValueError, FileNotFoundError) as e:
        print(f"ERROR: {e}")
        sys.exit(1)

    yf_start = (d0 - pd.Timedelta(days=400)).strftime("%Y-%m-%d")
    # yfinance ``end`` is exclusive; pad so the last Theta session is included.
    yf_end = (d1 + pd.Timedelta(days=14)).strftime("%Y-%m-%d")
    spy_wide = normalize_spy_df(load_spy_vix_from_yfinance(yf_start, yf_end))

    ld = ThetaChunksLoader(theta_dir, spy_df=spy_wide)
    days = trading_days_intersecting_spy(ld, spy_wide.index, d0, d1)
    if not days:
        print("ERROR: No overlapping Theta chain dates vs SPY/VIX panel.")
        sys.exit(1)
    if args.start.strip():
        t0 = pd.Timestamp(args.start.strip())
        days = [d for d in days if d >= t0]
    if args.end.strip():
        t1 = pd.Timestamp(args.end.strip())
        days = [d for d in days if d <= t1]
    if int(args.max_days) > 0:
        days = days[: int(args.max_days)]
    if not days:
        print("ERROR: No trading days left after --start/--end/--max-days.")
        sys.exit(1)

    cap = float(args.capital)
    cfg_path = (
        args.strategy_config.expanduser()
        if args.strategy_config is not None
        else DEFAULT_STRATEGY_CONFIG_PATH
    )
    if cfg_path.is_file():
        _cfg = load_strategy_config_file(cfg_path)
        apply_strategy_params_to_vrp_backtester_module(_cfg.strategy_params)
        bt_kw: dict = {
            "initial_capital": cap,
            "spy_df": spy_wide,
            "vol_risk_scaling": not bool(args.no_vol_scaling),
            "r2_crossover_filters": not bool(args.no_r2_crossover_filters),
            "dd_risk_scaling": bool(args.dd_risk_scaling),
            "dd_scale_enter": float(args.dd_enter),
            "dd_scale_exit": float(args.dd_exit),
            "dd_scale_mult": float(args.dd_mult),
            "sleeve_risk_fractions": cast(dict, dict(_cfg.sleeve_risk_fractions)),
            "overlay_risk_fractions": dict(_cfg.overlay_risk_fractions) if _cfg.overlay_risk_fractions else None,
            "overlay_risk_cap_frac": _cfg.overlay_risk_cap_frac,
            "total_risk_cap_frac": _cfg.total_risk_cap_frac,
            "overlap_portfolio": bool(args.overlap_portfolio),
            "overlap_slice_contracts": int(args.overlap_slice_contracts),
            "disable_pmcc": bool(args.no_pmcc),
            "r2_vix_scale_contracts": bool(args.r2_vix_scale_contracts),
            "r2_term_structure_gate": bool(args.r2_term_structure_gate),
            "macro_overlay_enabled": bool(args.macro_overlay),
            "macro_overlay_total_frac": float(args.macro_overlay_frac),
            "macro_overlay_requires_spy200": not bool(args.macro_overlay_no_spy200),
            "macro_overlay_exclude_optional_etf": bool(args.macro_overlay_exclude_putw),
        }
        _parity_note = f"Parity JSON: {cfg_path.resolve()}"
    else:
        bt_kw = {
            "initial_capital": cap,
            "spy_df": spy_wide,
            "vol_risk_scaling": not bool(args.no_vol_scaling),
            "r2_crossover_filters": not bool(args.no_r2_crossover_filters),
            "dd_risk_scaling": bool(args.dd_risk_scaling),
            "dd_scale_enter": float(args.dd_enter),
            "dd_scale_exit": float(args.dd_exit),
            "dd_scale_mult": float(args.dd_mult),
            "overlap_portfolio": bool(args.overlap_portfolio),
            "overlap_slice_contracts": int(args.overlap_slice_contracts),
            "disable_pmcc": bool(args.no_pmcc),
            "r2_vix_scale_contracts": bool(args.r2_vix_scale_contracts),
            "r2_term_structure_gate": bool(args.r2_term_structure_gate),
            "macro_overlay_enabled": bool(args.macro_overlay),
            "macro_overlay_total_frac": float(args.macro_overlay_frac),
            "macro_overlay_requires_spy200": not bool(args.macro_overlay_no_spy200),
            "macro_overlay_exclude_optional_etf": bool(args.macro_overlay_exclude_putw),
        }
        _parity_note = f"No parity JSON at {cfg_path.resolve()} (engine defaults)"
    if args.slippage is not None:
        bt_kw["slippage_factor"] = float(args.slippage)
    bt = VRPBacktester(ld, **bt_kw)

    print("=" * 72)
    print(" 4-Regime VRP | ThetaData 15:45 ET chunks | SPY > SMA(200) | sleeve risk from JSON")
    print(f" {_parity_note}")
    print(f" Theta dir: {theta_dir}")
    print(f" Window:    {d0.date()} → {d1.date()}  |  {len(days)} trading days")
    print(f" Capital:   ${cap:,.0f}")
    print(
        f" VVIX/VIX scaling: {'OFF' if args.no_vol_scaling else 'ON (global VVIX + R2 stress)'}",
    )
    _r2a = float(bt.sleeve_risk_fractions["diagonal"])
    _r2b = float(bt.sleeve_risk_fractions["r2_spread"])
    print(
        f" R2: R2a diagonal + R2b PCS same day (separate exits) | {_r2a:.1%} + {_r2b:.1%} sleeves × vol scaling | "
        "crossover "
        + ("OFF" if args.no_r2_crossover_filters else "ON (SMA50 + VIX MA/max)"),
    )
    if args.dd_risk_scaling:
        print(
            f" DD risk scaling: ON | enter ≥{args.dd_enter:.0%} → ×{args.dd_mult:.2f} | exit ≤{args.dd_exit:.0%}",
        )
    else:
        print(" DD risk scaling: OFF (use --dd-risk-scaling to enable)")
    if args.overlap_portfolio:
        vix_scale_tag = " +VIX-scale" if args.r2_vix_scale_contracts else ""
        print(
            f" Overlap portfolio: ON | +{int(args.overlap_slice_contracts)} con/sleeve/day{vix_scale_tag} "
            "(each ticket: own TP/SL/time; sleeve cap = legacy target_risk)",
        )
    else:
        print(" Overlap portfolio: OFF (flat book before next regime wave; use --overlap-portfolio)")
    if args.no_pmcc:
        print(" PMCC/R1: DISABLED (--no-pmcc) — VIX<12 complacency regime skipped entirely")
    if args.r2_term_structure_gate:
        print(" R2 term-structure gate: ON — blocks R2 entries when VIX9D > VIX30 (inverted front-end)")
    if args.macro_overlay:
        print(
            f" Macro overlay: ON | frac={float(args.macro_overlay_frac):.2%} of capital × mean daily "
            f"Tier A return | SPY>SMA200 gate={'OFF' if args.macro_overlay_no_spy200 else 'ON'} | "
            f"exclude PUTW/PBP={'ON' if args.macro_overlay_exclude_putw else 'OFF'}",
        )
    else:
        print(" Macro overlay: OFF (use --macro-overlay for Tier A Yahoo sleeve by VIX band)")
    print("=" * 72)

    bt.run_backtest(trading_days=days, show_progress=not bool(args.no_progress))
    bt.print_metrics()

    if w_putw > 0.0:
        from RenTech.strategy_stack.benchmark_putw_like_multi_ticker import (
            equal_weight_putw_portfolio_equity_series,
        )

        win_start = pd.Timestamp(days[0]).normalize()
        win_end = pd.Timestamp(days[-1]).normalize()
        vrp_eq_d = _vrp_cash_equity_on_trading_days(bt, days)
        putw_eq = equal_weight_putw_portfolio_equity_series(
            theta_dir.resolve(),
            [r.strip().upper() for r in str(args.putw_like_roots).split(",") if r.strip()],
            win_start,
            win_end,
            float(cap),
            dte_target=int(args.putw_like_dte_target),
            min_dte=int(args.putw_like_min_dte),
            max_dte=int(args.putw_like_max_dte),
        )
        putw_on = putw_eq.reindex(vrp_eq_d.index).ffill().bfill()
        comb = (1.0 - w_putw) * vrp_eq_d + w_putw * putw_on
        mc = _metrics_from_equity_series(comb, float(cap))
        ccomb = f"{mc['cagr']:.2%}" if math.isfinite(mc["cagr"]) else "n/a"
        print(
            f"\nSummary (VRP + PUTW-like multi-ticker @ {w_putw:.1%}): "
            f"end ${mc['ending_capital']:,.2f} | return {mc['total_return']:.2%} | "
            f"max DD {mc['max_drawdown']:.2%} | CAGR {ccomb}  "
            f"| blend: (1-{w_putw:.3f})×VRP_cash + {w_putw:.3f}×EW_PUTW on {len(vrp_eq_d)} days "
            f"({win_start.date()} → {win_end.date()})"
        )

    if args.export_trades_jsonl is not None:
        outp = args.export_trades_jsonl.expanduser()
        outp.parent.mkdir(parents=True, exist_ok=True)

        def _trade_to_dict(t: ClosedTrade) -> dict:
            return {
                "entry_date": pd.Timestamp(t.entry_date).isoformat(),
                "exit_date": pd.Timestamp(t.exit_date).isoformat(),
                "pnl_usd": float(t.pnl_usd),
                "exit_reason": str(t.exit_reason),
                "regime": str(t.regime),
                "days_in_trade": int(t.days_in_trade),
                "qty": int(t.qty),
                "initial_net_premium": float(t.initial_net_premium),
                "max_margin": float(t.max_margin),
                "legs_json": str(getattr(t, "legs_json", "") or ""),
            }

        with outp.open("w", encoding="utf-8") as f:
            for tr in bt.trade_log:
                f.write(json.dumps(_trade_to_dict(tr)) + "\n")
        print(f"Wrote {len(bt.trade_log)} trades to {outp}")

        if args.full_portfolio_report:
            from RenTech.strategy_stack.portfolio_vrp_plus_vxx import execute_portfolio_merge

            merge_kw: dict = {
                "vrp_trades": outp,
                "total_portfolio_capital": cap,
                "print_vxx_sweep": not bool(args.no_portfolio_vxx_sweep),
            }
            if args.portfolio_merge_json is not None:
                mj = args.portfolio_merge_json.expanduser()
                if not mj.is_file():
                    print(f"ERROR: --portfolio-merge-json not found: {mj.resolve()}", file=sys.stderr)
                    sys.exit(1)
                try:
                    extra = dict(merge_capital_from_allocation_json(mj, total_capital=cap))
                except (json.JSONDecodeError, OSError, ValueError) as e:
                    print(f"ERROR: invalid --portfolio-merge-json {mj}: {e}", file=sys.stderr)
                    sys.exit(1)
                cap_vrp_m = extra.pop("capital_vrp", None)
                if cap_vrp_m is not None:
                    merge_kw["capital_vrp"] = float(cap_vrp_m)
                merge_kw.update(extra)
                print(
                    f"Portfolio merge sleeves from {mj.resolve()}  "
                    f"(put ${extra['capital_put']:,.0f}, straddle ${extra['capital_straddle']:,.0f}, "
                    f"RR ${extra['capital_risk_reversal']:,.0f}, VXX ${extra['capital_vxx']:,.0f} "
                    f"@ {extra['vxx_bear_pct']:.0f}/{extra['vxx_call_pct']:.0f} bear/call)"
                )
            else:
                print(
                    "NOTE: merge uses default overlay/VXX risk budgets ($10k/$10k/$10k put/straddle/RR, "
                    "$15k VXX). For optimized fractions use --portfolio-merge-json "
                    "RenTech/strategy_stack/overlay_risk_fracs_optimized.json"
                )

            try:
                merge_frame = execute_portfolio_merge(**merge_kw)
            except FileNotFoundError as e:
                print(f"ERROR: {e}", file=sys.stderr)
                sys.exit(1)
    m = bt.metrics()
    _cagr = m["cagr"]
    cagr_s = f"{_cagr:.2%}" if isinstance(_cagr, float) and math.isfinite(_cagr) else "n/a"
    print(
        f"\nSummary (VRP book only): end ${m['ending_capital']:,.2f} | return {m['total_return']:.2%} | "
        f"max DD {m['max_drawdown']:.2%} | CAGR {cagr_s} | trades {m['total_trades']}"
    )
    if args.export_trades_jsonl is not None and args.full_portfolio_report and merge_frame is not None:
        from RenTech.strategy_stack.portfolio_vrp_plus_vxx import _metrics_block

        mf = _metrics_block(merge_frame["eq_full_portfolio"], "FULL")
        cfull = (
            f"{mf['cagr_pct'] / 100.0:.2%}"
            if isinstance(mf["cagr_pct"], (int, float)) and math.isfinite(mf["cagr_pct"])
            else "n/a"
        )
        print(
            f"Summary (FULL merged book): end ${mf['end_equity']:,.2f} | return {mf['return_pct'] / 100.0:.2%} | "
            f"max DD {mf['max_dd_pct'] / 100.0:.2%} | CAGR {cfull}  "
            f"(see portfolio table above; same horizon as merge date range)"
        )


if __name__ == "__main__":
    main()
