"""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()