"""ORB Exit Rule Sweeper. Replays entry events from an ORB intraday backtest run against 5-min bars under alternative exit rule combinations. Pure post-hoc simulation — does not change entries, sizing, or capital allocation. Each trade gets a per-rule realized_R. Usage: python scripts/orb_exit_sweep.py \ --run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \ --out tmp/orb_exit_sweep_v49_91_200d """ from __future__ import annotations import argparse import itertools import json from dataclasses import dataclass 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 @dataclass(frozen=True) class Rule: profit_target: float | None trailing_activation: float | None trailing_dist: float | None partial_at: float | None # None or e.g. 0.5 partial_size: float = 0.5 move_stop_to_be_after_partial: bool = True time_no_new_high: int | None = None # minutes; if no new running peak in this many minutes, exit initial_stop: float = -1.0 # in R, baseline matches V49.91 ATR stop sizing def label(self) -> str: parts = [] parts.append(f"pt={self.profit_target}" if self.profit_target is not None else "pt=none") if self.trailing_activation is not None and self.trailing_dist is not None: parts.append(f"tr={self.trailing_activation}/{self.trailing_dist}") else: parts.append("tr=none") if self.partial_at is not None: parts.append(f"px={self.partial_at}") else: parts.append("px=none") parts.append(f"tNH={self.time_no_new_high}" if self.time_no_new_high else "tNH=none") return "|".join(parts) def _infer_risk_per_share(t: dict) -> float: 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) @dataclass class Bars: """Per-trade favorable-direction R-multiple time series.""" ts_min: np.ndarray fav_high: np.ndarray fav_low: np.ndarray fav_close: np.ndarray fav_next_open: np.ndarray # last bar's "next open" = last close @classmethod def from_trade(cls, t: dict, intraday_root: Path) -> "Bars | None": ticker = t["ticker"] date = t["date"] bar_path = intraday_root / ticker / f"{date}.parquet" if not bar_path.exists(): return None entry_time = pd.Timestamp(t["entry_time"]).tz_convert("UTC") entry_price = float(t["entry_price"]) direction = t.get("orb_direction", "long") risk = _infer_risk_per_share(t) 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 None ts_min = ((win["ts"] - entry_time).dt.total_seconds() / 60.0).to_numpy() opens = win["open"].to_numpy() highs = win["high"].to_numpy() lows = win["low"].to_numpy() closes = win["close"].to_numpy() next_open = np.append(opens[1:], closes[-1]) 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 else: fav_high = (entry_price - lows) / risk fav_low = (entry_price - highs) / risk fav_close = (entry_price - closes) / risk fav_next_open = (entry_price - next_open) / risk return cls(ts_min, fav_high, fav_low, fav_close, fav_next_open) def simulate(bars: Bars, rule: Rule) -> tuple[float, str, int]: """Return (realized_R, exit_reason, exit_bar_index) under the rule. Same-bar precedence: stop > target > partial. If multiple events trigger inside one 5-min bar (5-min ambiguity), we always assume the adverse outcome wins to stay conservative. """ realized_r = 0.0 pos = 1.0 stop_r = rule.initial_stop partial_done = False running_peak = -np.inf last_new_high_min = bars.ts_min[0] n = len(bars.ts_min) for i in range(n): fhi, flo, fcl, fno = ( bars.fav_high[i], bars.fav_low[i], bars.fav_close[i], bars.fav_next_open[i], ) # 1. Stop check (conservative — assume bar low touches first if it crosses stop). if flo <= stop_r: realized_r += pos * stop_r return realized_r, "stop", i # 2. Profit target. if rule.profit_target is not None and fhi >= rule.profit_target: realized_r += pos * rule.profit_target return realized_r, "target", i # 3. Partial exit (one-shot). if ( not partial_done and rule.partial_at is not None and fhi >= rule.partial_at ): realized_r += rule.partial_size * rule.partial_at pos -= rule.partial_size partial_done = True if rule.move_stop_to_be_after_partial: stop_r = max(stop_r, 0.0) # 4. Update running peak / trailing. if fhi > running_peak: running_peak = fhi last_new_high_min = bars.ts_min[i] if ( rule.trailing_activation is not None and rule.trailing_dist is not None and running_peak >= rule.trailing_activation ): stop_r = max(stop_r, running_peak - rule.trailing_dist) # 5. Time-no-new-high stop (exit at next-bar open or, on last bar, current close). if ( rule.time_no_new_high is not None and running_peak >= 0 # only after we ever went green and (bars.ts_min[i] - last_new_high_min) >= rule.time_no_new_high ): exec_r = fno if i < n - 1 else fcl realized_r += pos * exec_r return realized_r, "no_new_high", i # EOD: exit remaining at last close. realized_r += pos * bars.fav_close[-1] return realized_r, "eod_close", n - 1 def make_grid() -> list[Rule]: profit_targets = [None, 0.8, 1.2] trail_specs = [ (None, None), (0.5, 0.3), (1.0, 0.3), (1.0, 0.5), ] partial_specs = [(None, 0.0), (0.5, 0.5)] time_nh = [None, 30, 60] out = [] for pt, (ta, td), (pa, ps), tnh in itertools.product( profit_targets, trail_specs, partial_specs, time_nh ): out.append( Rule( profit_target=pt, trailing_activation=ta, trailing_dist=td, partial_at=pa, partial_size=ps, time_no_new_high=tnh, ) ) return out def evaluate_rule( rule: Rule, bars_list: list[Bars], realized_baseline: np.ndarray ) -> dict: rs = np.zeros(len(bars_list)) reasons = [] for i, b in enumerate(bars_list): r, why, _ = simulate(b, rule) rs[i] = r reasons.append(why) arr = pd.Series(rs) delta = rs - realized_baseline return { "rule": rule.label(), "profit_target": rule.profit_target, "trailing_activation": rule.trailing_activation, "trailing_dist": rule.trailing_dist, "partial_at": rule.partial_at, "time_no_new_high": rule.time_no_new_high, "n": len(rs), "total_r": float(rs.sum()), "mean_r": float(arr.mean()), "median_r": float(arr.median()), "win_rate": float((arr > 0).mean()), "p25_r": float(arr.quantile(0.25)), "p75_r": float(arr.quantile(0.75)), "worst_r": float(arr.min()), "best_r": float(arr.max()), "delta_total_r": float(delta.sum()), "delta_mean_r": float(delta.mean()), "delta_median_r": float(np.median(delta)), "wins_added": int(((rs > 0) & (realized_baseline <= 0)).sum()), "wins_lost": int(((rs <= 0) & (realized_baseline > 0)).sum()), "reason_counts": dict(pd.Series(reasons).value_counts().to_dict()), } def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--run", required=True) ap.add_argument("--out", required=True) ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE)) args = ap.parse_args() out_dir = Path(args.out) out_dir.mkdir(parents=True, exist_ok=True) intraday_root = Path(args.intraday_cache) payload = json.loads(Path(args.run).read_text()) trades = payload["trades"] print(f"Loaded {len(trades)} trades") bars_list: list[Bars] = [] realized_baseline = [] trade_meta = [] skipped = 0 for t in trades: b = Bars.from_trade(t, intraday_root) if b is None: skipped += 1 continue bars_list.append(b) realized_baseline.append(float(t.get("r_multiple_at_exit") or 0.0)) trade_meta.append( { "ticker": t["ticker"], "date": t["date"], "entry_time": t["entry_time"], "rvol": t.get("rvol"), "gap_pct": t.get("gap_pct"), "entry_dollar_volume": t.get("entry_dollar_volume"), "candidate_score": t.get("candidate_score"), } ) realized_baseline = np.asarray(realized_baseline) print(f"Prepared {len(bars_list)} trade bar series (skipped {skipped})") grid = make_grid() print(f"Evaluating {len(grid)} rule combos") results = [] per_trade_long = [] # rule × trade for stratification for k, rule in enumerate(grid): row = evaluate_rule(rule, bars_list, realized_baseline) results.append(row) # also persist per-trade R under this rule for downstream stratification for i, b in enumerate(bars_list): r, why, _ = simulate(b, rule) per_trade_long.append( { "rule": rule.label(), **trade_meta[i], "realized_r_baseline": realized_baseline[i], "realized_r_rule": r, "exit_reason_rule": why, } ) if (k + 1) % 10 == 0: print(f" evaluated {k+1}/{len(grid)} rules") df = pd.DataFrame(results).sort_values("total_r", ascending=False).reset_index(drop=True) long_df = pd.DataFrame(per_trade_long) # Baseline reference row baseline_total = float(realized_baseline.sum()) baseline_mean = float(realized_baseline.mean()) df.to_parquet(out_dir / "rule_results.parquet", index=False) long_df.to_parquet(out_dir / "per_trade_long.parquet", index=False) print(f"Wrote rule_results.parquet ({len(df)} rules) and per_trade_long.parquet ({len(long_df)} rows)") # Markdown summary md = build_markdown(df, long_df, baseline_total, baseline_mean, payload.get("metrics", {})) (out_dir / "summary.md").write_text(md) print(f"Wrote summary.md") def build_markdown( df: pd.DataFrame, long_df: pd.DataFrame, baseline_total: float, baseline_mean: float, run_meta: dict, ) -> str: n_trades = int(df.iloc[0]["n"]) if len(df) else 0 lines = [ "# ORB Exit Rule Sweep — V49.91 baseline 200d", "", f"**Run:** `{run_meta.get('run_id')}` " f"({run_meta.get('start_date')} → {run_meta.get('end_date')}, " f"{n_trades} trades audited)", "", f"**Baseline (V49.91 actual):** total_R = **{baseline_total:.2f}**, " f"mean_R = **{baseline_mean:.3f}**", "", "## Top 15 rules by total_R", "", "| rank | rule | total_R | Δ vs base | mean_R | win | p25 | p75 | worst | wins+ | wins- |", "|---|---|---|---|---|---|---|---|---|---|---|", ] for i, row in df.head(15).iterrows(): lines.append( f"| {i+1} | `{row['rule']}` | " f"{row['total_r']:.2f} | " f"{row['delta_total_r']:+.2f} | " f"{row['mean_r']:.3f} | " f"{row['win_rate']:.1%} | " f"{row['p25_r']:.2f} | {row['p75_r']:.2f} | " f"{row['worst_r']:.2f} | " f"{int(row['wins_added'])} | {int(row['wins_lost'])} |" ) lines.append("") lines.append("## Worst 5 rules (sanity check)") lines.append("") lines.append("| rule | total_R | mean_R | win |") lines.append("|---|---|---|---|") for _, row in df.tail(5).iterrows(): lines.append( f"| `{row['rule']}` | {row['total_r']:.2f} | " f"{row['mean_r']:.3f} | {row['win_rate']:.1%} |" ) lines.append("") # Plateau check: median rule + neighbors by single-axis variation lines.append("## Plateau sensitivity (top rule and ±1 step neighbors)") lines.append("") top = df.iloc[0] lines.append(f"Top rule: `{top['rule']}` total_R={top['total_r']:.2f}") lines.append("") lines.append("Distribution of total_R across all rules:") lines.append( f"- min={df['total_r'].min():.2f}, " f"p25={df['total_r'].quantile(0.25):.2f}, " f"median={df['total_r'].median():.2f}, " f"p75={df['total_r'].quantile(0.75):.2f}, " f"max={df['total_r'].max():.2f}" ) lines.append( f"- rules with total_R within 5% of best: " f"**{int((df['total_r'] >= top['total_r'] * 0.95).sum())}**" ) lines.append( f"- rules with total_R better than baseline: " f"**{int((df['total_r'] > baseline_total).sum())}/{len(df)}**" ) lines.append("") # Stratification of top rule vs baseline by time-to-peak bucket lines.append("## Top rule vs baseline by time-to-peak bucket") lines.append("") bins = [-1, 5, 15, 30, 60, 120, 240, 1000] labels = ["≤5m", "5-15m", "15-30m", "30-60m", "1-2h", "2-4h", ">4h"] audit_path = Path(run_meta.get("_audit_parquet", "")) if run_meta else None audit_df = None candidate_audit = Path("tmp/orb_exit_audit_v49_91_200d/trade_audit.parquet") if candidate_audit.exists(): audit_df = pd.read_parquet(candidate_audit) if audit_df is not None: merged = ( long_df[long_df["rule"] == top["rule"]] .merge( audit_df[["ticker", "date", "minutes_to_peak"]], on=["ticker", "date"], how="left", ) ) merged["peak_bucket"] = pd.cut(merged["minutes_to_peak"], bins=bins, labels=labels) agg = merged.groupby("peak_bucket", observed=False).agg( n=("realized_r_rule", "size"), base_mean=("realized_r_baseline", "mean"), rule_mean=("realized_r_rule", "mean"), base_win=("realized_r_baseline", lambda s: float((s > 0).mean())), rule_win=("realized_r_rule", lambda s: float((s > 0).mean())), base_total=("realized_r_baseline", "sum"), rule_total=("realized_r_rule", "sum"), ) lines.append( "| bucket | n | base mean | rule mean | Δmean | base win | rule win | base total | rule total | Δtotal |" ) lines.append("|" + "---|" * 10) for k, r in agg.iterrows(): lines.append( f"| {k} | {int(r['n'])} | " f"{r['base_mean']:.3f} | {r['rule_mean']:.3f} | " f"{r['rule_mean'] - r['base_mean']:+.3f} | " f"{r['base_win']:.1%} | {r['rule_win']:.1%} | " f"{r['base_total']:.2f} | {r['rule_total']:.2f} | " f"{r['rule_total'] - r['base_total']:+.2f} |" ) lines.append("") # Rule-axis sensitivity tables: marginal effect of one knob holding others for axis, col in [ ("profit_target", "profit_target"), ("trailing", "trailing_activation"), ("partial_at", "partial_at"), ("time_no_new_high", "time_no_new_high"), ]: agg = df.groupby(col, dropna=False).agg( n=("total_r", "size"), best_total=("total_r", "max"), mean_total=("total_r", "mean"), median_total=("total_r", "median"), best_mean_r=("mean_r", "max"), ) lines.append(f"### Marginal effect: {axis}") lines.append("") lines.append("| value | n_combos | best total_R | mean total_R | median total_R |") lines.append("|---|---|---|---|---|") for k, r in agg.iterrows(): lines.append( f"| {k} | {int(r['n'])} | {r['best_total']:.2f} | " f"{r['mean_total']:.2f} | {r['median_total']:.2f} |" ) lines.append("") return "\n".join(lines) if __name__ == "__main__": main()