"""ORB Exit Opportunity Audit.

For each trade in an ORB intraday backtest run, joins entry-to-EOD 5-min bars and
computes MFE / MAE / capture-ratio / giveback / time-to-peak. Writes per-trade
parquet plus a summary markdown with stratifications by exit_reason, quality
buckets, and regime context.

Usage:
    python scripts/orb_exit_audit.py \
        --run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \
        --out tmp/orb_exit_audit_v49_91_200d
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path

import numpy as np
import pandas as pd

ET = "America/New_York"
DEFAULT_INTRADAY_CACHE = Path("data/cache/intraday")
DEFAULT_ATR_STOP_MULT = 0.75  # V49.91 orb_strategy.atr_stop_multiplier


def _infer_risk_per_share(t: dict) -> float:
    """Recover initial-stop dollars per share from realized R, with ATR fallback."""
    r = t.get("r_multiple_at_exit")
    entry = t["entry_price"]
    exit_p = t["exit_price"]
    direction = t.get("orb_direction", "long")
    if r not in (None, 0) and abs(r) > 1e-6:
        if direction == "long":
            return (exit_p - entry) / r
        return (entry - exit_p) / r
    atr = t.get("atr_at_entry") or 0.0
    return max(atr * DEFAULT_ATR_STOP_MULT, 1e-6)


def _favorable_r(prices: np.ndarray, entry: float, risk: float, direction: str) -> np.ndarray:
    """Return per-bar R-multiple in the favorable direction."""
    if direction == "long":
        return (prices - entry) / risk
    return (entry - prices) / risk


def _adverse_r(prices: np.ndarray, entry: float, risk: float, direction: str) -> np.ndarray:
    """Return per-bar R-multiple in the adverse direction."""
    if direction == "long":
        return (prices - entry) / risk
    return (entry - prices) / risk


def _first_cross_minutes(
    favorable_high_r: np.ndarray, ts_minutes: np.ndarray, threshold: float
) -> float:
    """Minutes after entry when favorable_high_r first reaches threshold; NaN if never."""
    hits = np.where(favorable_high_r >= threshold)[0]
    if hits.size == 0:
        return np.nan
    return float(ts_minutes[hits[0]])


def _audit_trade(t: dict, intraday_root: Path) -> dict | None:
    ticker = t["ticker"]
    date = t["date"]
    bar_path = intraday_root / ticker / f"{date}.parquet"
    if not bar_path.exists():
        return {"ticker": ticker, "date": date, "missing_bars": True}

    entry_time = pd.Timestamp(t["entry_time"]).tz_convert("UTC")
    exit_time = pd.Timestamp(t["exit_time"]).tz_convert("UTC")
    entry_price = float(t["entry_price"])
    exit_price = float(t["exit_price"])
    direction = t.get("orb_direction", "long")
    realized_r = float(t.get("r_multiple_at_exit") or 0.0)
    risk = _infer_risk_per_share(t)

    # Trade-day window: entry → 16:00 ET on the trade date (covers max-hold and EOD exits).
    et_close_local = pd.Timestamp(date, tz=ET) + pd.Timedelta(hours=16)
    et_close_utc = et_close_local.tz_convert("UTC")

    bars = pd.read_parquet(bar_path)
    bars["ts"] = pd.to_datetime(bars["timestamp"], utc=True)
    win = bars[(bars["ts"] >= entry_time) & (bars["ts"] <= et_close_utc)].sort_values("ts").reset_index(drop=True)
    if win.empty:
        return {"ticker": ticker, "date": date, "missing_bars": True}

    ts_min = ((win["ts"] - entry_time).dt.total_seconds() / 60.0).to_numpy()
    highs = win["high"].to_numpy()
    lows = win["low"].to_numpy()
    closes = win["close"].to_numpy()
    # Next-bar open: shift open by -1; last bar falls back to its own close (final actionable price).
    opens = win["open"].to_numpy()
    next_open = np.append(opens[1:], closes[-1])

    # Favorable-direction price arrays (long uses high/low; short flips).
    if direction == "long":
        fav_high = (highs - entry_price) / risk
        fav_low = (lows - entry_price) / risk
        fav_close = (closes - entry_price) / risk
        fav_next_open = (next_open - entry_price) / risk
        peak_idx = int(np.argmax(highs))
        trough_idx = int(np.argmin(lows))
        peak_price = float(highs[peak_idx])
        trough_price = float(lows[trough_idx])
    else:
        fav_high = (entry_price - lows) / risk  # high in favorable space = adverse low's mirror
        fav_low = (entry_price - highs) / risk
        fav_close = (entry_price - closes) / risk
        fav_next_open = (entry_price - next_open) / risk
        peak_idx = int(np.argmin(lows))
        trough_idx = int(np.argmax(highs))
        peak_price = float(lows[peak_idx])
        trough_price = float(highs[trough_idx])

    mfe_r = float(fav_high.max())
    mae_r = float(fav_low.min())
    oracle_close_r = float(fav_close.max())
    oracle_next_open_r = float(fav_next_open.max())

    peak_ts = win.loc[peak_idx, "ts"]
    trough_ts = win.loc[trough_idx, "ts"]
    minutes_to_peak = (peak_ts - entry_time).total_seconds() / 60.0
    minutes_to_trough = (trough_ts - entry_time).total_seconds() / 60.0
    minutes_in_trade = (exit_time - entry_time).total_seconds() / 60.0
    minutes_window = (et_close_utc - entry_time).total_seconds() / 60.0

    capture_vs_high = realized_r / mfe_r if mfe_r > 0.05 else np.nan
    capture_vs_close = realized_r / oracle_close_r if oracle_close_r > 0.05 else np.nan
    capture_vs_next_open = realized_r / oracle_next_open_r if oracle_next_open_r > 0.05 else np.nan
    giveback_r = mfe_r - realized_r
    giveback_close_r = oracle_close_r - realized_r

    # MFE / max-close within early windows (lookback-free at horizon t).
    def _within(mins: float) -> tuple[float, float]:
        mask = ts_min <= mins
        if not mask.any():
            return np.nan, np.nan
        return float(fav_high[mask].max()), float(fav_close[mask].max())

    mfe_before_30m, max_close_before_30m = _within(30)
    mfe_before_60m, max_close_before_60m = _within(60)

    # First-touch times to favorable thresholds.
    first_profit_0_3r = _first_cross_minutes(fav_high, ts_min, 0.3)
    first_profit_0_5r = _first_cross_minutes(fav_high, ts_min, 0.5)
    first_profit_0_8r = _first_cross_minutes(fav_high, ts_min, 0.8)
    first_profit_1_0r = _first_cross_minutes(fav_high, ts_min, 1.0)

    # Did price reach +0.5R then give back ≥0.5R from its running peak (long)?
    if mfe_r >= 0.5:
        running_peak = np.maximum.accumulate(fav_high)
        giveback_after_peak = running_peak - fav_low  # bar-low against running peak
        gave_back_0_5r_after_profit = bool((giveback_after_peak >= 0.5).any())
    else:
        gave_back_0_5r_after_profit = False

    stop_after_mfe_0_5r = bool(t.get("exit_reason") == "stop_loss" and mfe_r >= 0.5)

    # Early-failure flag: hit ≤-0.5R within first 25 minutes.
    mask_25 = ts_min <= 25
    if mask_25.any():
        early_mae_r = float(fav_low[mask_25].min())
    else:
        early_mae_r = np.nan
    early_failure = bool(early_mae_r <= -0.5) if not np.isnan(early_mae_r) else False

    return {
        "ticker": ticker,
        "date": date,
        "direction": direction,
        "entry_time": entry_time,
        "exit_time": exit_time,
        "entry_price": entry_price,
        "exit_price": exit_price,
        "risk_per_share": risk,
        "atr_at_entry": t.get("atr_at_entry"),
        "realized_r": realized_r,
        # Oracle benchmarks
        "mfe_r": mfe_r,                    # = oracle_high_r (theoretical upper bound)
        "mae_r": mae_r,
        "oracle_close_r": oracle_close_r,
        "oracle_next_open_r": oracle_next_open_r,
        # Capture ratios at three execution assumptions
        "capture_vs_high": capture_vs_high,
        "capture_vs_close": capture_vs_close,
        "capture_vs_next_open": capture_vs_next_open,
        "giveback_from_peak_r": giveback_r,
        "giveback_vs_close_r": giveback_close_r,
        # Early MFE / close milestones
        "mfe_before_30m": mfe_before_30m,
        "mfe_before_60m": mfe_before_60m,
        "max_close_before_30m": max_close_before_30m,
        "max_close_before_60m": max_close_before_60m,
        # First-touch times (minutes from entry)
        "first_profit_0_3r_min": first_profit_0_3r,
        "first_profit_0_5r_min": first_profit_0_5r,
        "first_profit_0_8r_min": first_profit_0_8r,
        "first_profit_1_0r_min": first_profit_1_0r,
        # Pattern flags
        "gave_back_0_5r_after_profit": gave_back_0_5r_after_profit,
        "stop_after_mfe_0_5r": stop_after_mfe_0_5r,
        "early_mae_r_25min": early_mae_r,
        "early_failure_25min": early_failure,
        # Timing
        "minutes_to_peak": minutes_to_peak,
        "minutes_to_trough": minutes_to_trough,
        "minutes_in_trade": minutes_in_trade,
        "minutes_window": minutes_window,
        # Pass-through trade context
        "exit_reason": t.get("exit_reason"),
        "stop_level_at_exit": t.get("stop_level_at_exit"),
        "pnl_pct": t.get("pnl_pct"),
        "rvol": t.get("rvol"),
        "gap_pct": t.get("gap_pct"),
        "morning_gain_pct": t.get("morning_gain_pct"),
        "entropy_20d": t.get("entropy_20d"),
        "entry_dollar_volume": t.get("entry_dollar_volume"),
        "avg_dollar_vol_30d": t.get("avg_dollar_vol_30d"),
        "sector_confirmation_active": t.get("sector_confirmation_active"),
        "is_liquid_largecap": t.get("is_liquid_largecap"),
        "candidate_score": t.get("candidate_score"),
        "score_rank_pct": t.get("score_rank_pct"),
        "missing_bars": False,
    }


def _quartile_bucket(s: pd.Series) -> pd.Series:
    """Return 'Q1'..'Q4' labels by quartile rank, NaN-safe."""
    try:
        return pd.qcut(s, 4, labels=["Q1", "Q2", "Q3", "Q4"], duplicates="drop")
    except ValueError:
        return pd.Series([pd.NA] * len(s), index=s.index, dtype="object")


def _fmt(x, p=3):
    if pd.isna(x):
        return "n/a"
    if isinstance(x, (int, np.integer)):
        return f"{int(x)}"
    return f"{float(x):.{p}f}"


def _summary_section(df: pd.DataFrame, title: str, group_col: str) -> str:
    g = df.groupby(group_col, dropna=False, observed=False).agg(
        n=("realized_r", "size"),
        win_rate=("realized_r", lambda s: float((s > 0).mean())),
        realized_mean=("realized_r", "mean"),
        oracle_close_mean=("oracle_close_r", "mean"),
        oracle_next_open_mean=("oracle_next_open_r", "mean"),
        mfe_mean=("mfe_r", "mean"),
        cap_close_med=("capture_vs_close", "median"),
        cap_next_open_med=("capture_vs_next_open", "median"),
        cap_high_med=("capture_vs_high", "median"),
        giveback_close_med=("giveback_vs_close_r", "median"),
        giveback_high_med=("giveback_from_peak_r", "median"),
        min_to_peak_med=("minutes_to_peak", "median"),
        min_held_med=("minutes_in_trade", "median"),
    )
    lines = [f"### {title}", ""]
    lines.append(
        "| group | n | win | realR | oCloseR | oNextOpR | mfeR | "
        "cap_close | cap_nextOp | cap_high | gb_close | gb_high | "
        "min→peak | min held |"
    )
    lines.append("|" + "---|" * 14)
    for k, row in g.iterrows():
        lines.append(
            f"| {k} | {int(row.n)} | {_fmt(row.win_rate)} | "
            f"{_fmt(row.realized_mean)} | "
            f"{_fmt(row.oracle_close_mean)} | "
            f"{_fmt(row.oracle_next_open_mean)} | "
            f"{_fmt(row.mfe_mean)} | "
            f"{_fmt(row.cap_close_med)} | "
            f"{_fmt(row.cap_next_open_med)} | "
            f"{_fmt(row.cap_high_med)} | "
            f"{_fmt(row.giveback_close_med)} | "
            f"{_fmt(row.giveback_high_med)} | "
            f"{_fmt(row.min_to_peak_med, 0)} | "
            f"{_fmt(row.min_held_med, 0)} |"
        )
    lines.append("")
    return "\n".join(lines)


def build_summary(df: pd.DataFrame, run_meta: dict) -> str:
    n_missing = int(df["missing_bars"].sum()) if "missing_bars" in df else 0
    df = df[~df["missing_bars"].fillna(False)].copy() if "missing_bars" in df else df

    win_rate = float((df["realized_r"] > 0).mean())
    mfe_pos = (df["mfe_r"] > 0).sum()
    cap_close = df["capture_vs_close"].dropna()
    cap_next_open = df["capture_vs_next_open"].dropna()
    cap_high = df["capture_vs_high"].dropna()
    early_fail_rate = float(df["early_failure_25min"].mean())

    lines = [
        "# ORB Exit Opportunity Audit",
        "",
        f"**Run:** `{run_meta.get('run_id')}` ({run_meta.get('start_date')} → {run_meta.get('end_date')}, "
        f"{run_meta.get('trading_days')} days, {run_meta.get('total_trades')} trades)",
        "",
        "## Headline (3 capture benchmarks)",
        "",
        f"- Trades audited: **{len(df)}** (missing intraday bars: {n_missing})",
        f"- Win rate: **{win_rate:.1%}** | trades with positive MFE: **{int(mfe_pos)}/{len(df)}**",
        "",
        "Capture = realized_R / oracle_R. Compare three execution assumptions:",
        "",
        "| benchmark | description | median | mean | p25 | p75 |",
        "|---|---|---|---|---|---|",
        f"| **capture_vs_close** | exit at any future bar **close** (most actionable) | "
        f"**{_fmt(cap_close.median())}** | {_fmt(cap_close.mean())} | "
        f"{_fmt(cap_close.quantile(0.25))} | {_fmt(cap_close.quantile(0.75))} |",
        f"| capture_vs_next_open | exit at any future **next-bar open** (executable) | "
        f"{_fmt(cap_next_open.median())} | {_fmt(cap_next_open.mean())} | "
        f"{_fmt(cap_next_open.quantile(0.25))} | {_fmt(cap_next_open.quantile(0.75))} |",
        f"| capture_vs_high | exit at any future bar **high** (theoretical upper bound) | "
        f"{_fmt(cap_high.median())} | {_fmt(cap_high.mean())} | "
        f"{_fmt(cap_high.quantile(0.25))} | {_fmt(cap_high.quantile(0.75))} |",
        "",
        f"- Mean realized_R: **{_fmt(df['realized_r'].mean())}** | "
        f"oracle_close_R: **{_fmt(df['oracle_close_r'].mean())}** | "
        f"oracle_next_open_R: **{_fmt(df['oracle_next_open_r'].mean())}** | "
        f"mfe_R: **{_fmt(df['mfe_r'].mean())}**",
        f"- Avg giveback vs close: **{_fmt(df['giveback_vs_close_r'].mean())} R** | "
        f"vs high: **{_fmt(df['giveback_from_peak_r'].mean())} R**",
        f"- Early-failure (≤-0.5R within 25min): **{early_fail_rate:.1%}** | "
        f"Trades that hit +0.5R then gave back ≥0.5R: "
        f"**{df['gave_back_0_5r_after_profit'].mean():.1%}**",
        f"- Stop-loss exits with prior MFE ≥ +0.5R: "
        f"**{df['stop_after_mfe_0_5r'].sum()}/{(df['exit_reason']=='stop_loss').sum()}** "
        f"(of stop_loss trades)",
        f"- Median minutes to peak: **{_fmt(df['minutes_to_peak'].median(), 0)}** | "
        f"Median hold: **{_fmt(df['minutes_in_trade'].median(), 0)} min**",
        "",
        "## First-touch profit times (median minutes from entry)",
        "",
        "| level | hit rate | median minutes | p25 | p75 |",
        "|---|---|---|---|---|",
    ]
    for col, lvl in [
        ("first_profit_0_3r_min", "+0.3R"),
        ("first_profit_0_5r_min", "+0.5R"),
        ("first_profit_0_8r_min", "+0.8R"),
        ("first_profit_1_0r_min", "+1.0R"),
    ]:
        s = df[col]
        lines.append(
            f"| {lvl} | {s.notna().mean():.1%} | {_fmt(s.median(), 0)} | "
            f"{_fmt(s.quantile(0.25), 0)} | {_fmt(s.quantile(0.75), 0)} |"
        )
    lines += [
        "",
        "## Distribution snapshots",
        "",
        f"- realized_R quartiles:    {df['realized_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- oracle_close_R:          {df['oracle_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- oracle_next_open_R:      {df['oracle_next_open_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- mfe_R:                   {df['mfe_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- mae_R:                   {df['mae_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- capture_vs_close:        {cap_close.quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- giveback_vs_close_R:     {df['giveback_vs_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        "",
        "## Stratifications",
        "",
    ]

    lines.append(_summary_section(df, "By exit_reason", "exit_reason"))
    lines.append(_summary_section(df, "By stop_level_at_exit", "stop_level_at_exit"))

    df["rvol_q"] = _quartile_bucket(df["rvol"])
    df["gap_q"] = _quartile_bucket(df["gap_pct"])
    df["dvol_q"] = _quartile_bucket(df["entry_dollar_volume"])
    df["score_q"] = _quartile_bucket(df["candidate_score"])

    lines.append(_summary_section(df, "By RVol quartile", "rvol_q"))
    lines.append(_summary_section(df, "By gap_pct quartile", "gap_q"))
    lines.append(_summary_section(df, "By entry_dollar_volume quartile (proxy for Stocks-in-Play)", "dvol_q"))
    lines.append(_summary_section(df, "By candidate_score quartile", "score_q"))
    lines.append(_summary_section(df, "By is_liquid_largecap", "is_liquid_largecap"))
    lines.append(_summary_section(df, "By sector_confirmation_active", "sector_confirmation_active"))

    # Time-to-peak histogram
    bins = [-1, 5, 15, 30, 60, 120, 240, 1000]
    labels = ["≤5m", "5-15m", "15-30m", "30-60m", "1-2h", "2-4h", ">4h"]
    df["peak_bucket"] = pd.cut(df["minutes_to_peak"], bins=bins, labels=labels)
    lines.append(_summary_section(df, "By time-to-peak bucket", "peak_bucket"))

    # Reachability of fixed R-targets under high-touch vs close-confirm execution.
    lines.append("## Target-reachability (high-touch vs close-confirm)")
    lines.append("")
    lines.append(
        "How many trades **could have hit** target T under each execution assumption. "
        "high-touch assumes any wick at T fills (optimistic); close-confirm requires a 5-min bar "
        "to *close* at or above T (actionable lower bound)."
    )
    lines.append("")
    lines.append("| target | high-touch | close-confirm | next-open exec |")
    lines.append("|---|---|---|---|")
    for tgt in [0.3, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0]:
        hit_high = (df["mfe_r"] >= tgt).mean()
        hit_close = (df["oracle_close_r"] >= tgt).mean()
        hit_next_open = (df["oracle_next_open_r"] >= tgt).mean()
        lines.append(
            f"| {tgt:.1f}R | {hit_high:.1%} | {hit_close:.1%} | {hit_next_open:.1%} |"
        )
    lines.append("")

    return "\n".join(lines)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--run", required=True, help="Path to intraday_*.json run output")
    ap.add_argument("--out", required=True, help="Output directory")
    ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE))
    args = ap.parse_args()

    run_path = Path(args.run)
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    intraday_root = Path(args.intraday_cache)

    payload = json.loads(run_path.read_text())
    trades = payload["trades"]
    metrics = payload.get("metrics", {})
    print(f"Loaded {len(trades)} trades from {run_path}")

    rows = []
    for i, t in enumerate(trades):
        try:
            r = _audit_trade(t, intraday_root)
            if r is not None:
                rows.append(r)
        except Exception as e:
            print(f"  trade {i} {t.get('ticker')} {t.get('date')}: {e}")
        if (i + 1) % 25 == 0:
            print(f"  audited {i+1}/{len(trades)}")

    df = pd.DataFrame(rows)
    parquet_path = out_dir / "trade_audit.parquet"
    df.to_parquet(parquet_path, index=False)
    print(f"Wrote {parquet_path} ({len(df)} rows)")

    summary = build_summary(df, metrics)
    md_path = out_dir / "summary.md"
    md_path.write_text(summary)
    print(f"Wrote {md_path}")


if __name__ == "__main__":
    main()
