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