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.

479 lines
17 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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