You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

456 lines
19 KiB
Python

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