""" Gap-Down ORB Short Diagnostic (Option 3) Hypothesis: stocks that GAP DOWN ≥2% and have a bearish ORB (5-min ORB candle close < open) continue lower and offer a short-side edge that is regime-orthogonal to V23. Key difference vs Phase 3 (hypergap_failure_v1): - Phase 3 shorted GAP-UP stocks that FAILED: adverse R/R (rockets up when wrong) - This shorts GAP-DOWN stocks that CONTINUE lower: more symmetric R/R (when wrong: slow recovery, not a rocket; when right: fills gap to prev close) Setup: - Universe: midlarge tickers with daily cache (for gap computation) - Gap: today_open/prev_close - 1 ≤ -0.02 (gap down ≥ 2%) - ORB filter: 9:30-9:35 ET candle close < open (bearish ORB) - Entry: short below ORB low (when price breaks below ORB low) - Stop: above ORB high (ATR-based stop as alternative if ORB range too wide) - Target 1: prev_close (gap fill) — partial exit - Target 2: ATR * 1.5 below entry - Exit: EOD (15:55 ET) if no target hit Quality filters (same as V23): - min_price: 10.0 - min_avg_dollar_volume: 25M (proxy from daily bar volume * close) - min_rvol: 1.5 approx (first 5-min vol vs avg bar vol) - max_gap_pct: -0.10 (don't short extreme gap-downs — catastrophic reversal risk) Gates: - WR ≥ 42% (shorts can work at lower WR if R/R > 1.5) - avg_win / avg_loss ≥ 1.2 (need favorable R/R for shorts) - Pearson corr with V23 daily PnL ≤ 0.00 (should be negative or near-zero) """ from __future__ import annotations import json import math import os import sys from pathlib import Path import numpy as np import pandas as pd import yaml # ── Config ───────────────────────────────────────────────────────────────── CACHE_DIR = "data/cache/intraday" DAILY_CACHE_DIR = "data/cache/daily" UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml" V23_BASELINE_RUN = "runs/intraday_orb/intraday_20260420_205136_5597b16d.json" MIN_PRICE = 10.0 MIN_GAP_DOWN = -0.02 # gap ≤ -2% MAX_GAP_DOWN = -0.10 # don't short extreme gaps (≤ -10%) MIN_AVG_DOLLAR_VOL = 25_000_000 MIN_RVOL = 1.5 ATR_STOP_MULT = 1.0 # stop above ORB high (or ATR above entry if ORB range > ATR) RISK_PER_TRADE = 500.0 # $500 = 5% of $10k MAX_SIMULTANEOUS = 3 EXIT_HOUR = 15 EXIT_MIN = 55 def load_universe() -> list[str]: with open(UNIVERSE_FILE) as f: data = yaml.safe_load(f) return data.get("symbols", data) if isinstance(data, dict) else data def load_daily_cache(ticker: str) -> pd.DataFrame | None: path = f"{DAILY_CACHE_DIR}/{ticker}.parquet" if not os.path.exists(path): return None try: return pd.read_parquet(path) except Exception: return None def load_bars(ticker: str, date: str) -> pd.DataFrame | None: path = f"{CACHE_DIR}/{ticker}/{date}.parquet" if not os.path.exists(path): return None try: df = pd.read_parquet(path) if len(df) < 10: return None df["ts"] = pd.to_datetime(df["timestamp"]).dt.tz_convert("US/Eastern") df["hour"] = df["ts"].dt.hour df["minute"] = df["ts"].dt.minute df = df[(df["hour"] >= 9) & (df["hour"] < 16)].copy() df = df.sort_values("ts").reset_index(drop=True) return df except Exception: return None def approximate_atr(df: pd.DataFrame) -> float: ranges = df["high"] - df["low"] return ranges.mean() * math.sqrt(78) def compute_rvol(bars: pd.DataFrame, avg_daily_vol: float) -> float: """Approx rvol: first 5-min volume / (avg_daily_vol / 78).""" if avg_daily_vol <= 0 or len(bars) == 0: return 0.0 first_vol = bars.iloc[0]["volume"] return first_vol / (avg_daily_vol / 78) def simulate_day(date: str, tickers: list[str], daily_data: dict[str, pd.DataFrame]) -> dict: """Run gap-down short simulation for one day.""" day_trades: list[dict] = [] open_positions: int = 0 # Pre-filter candidates using daily data candidates = [] for ticker in tickers: if ticker not in daily_data: continue df = daily_data[ticker] idx = df.index[df["date"] == date].tolist() if not idx: continue row_idx = idx[0] if row_idx == 0: continue # no prev day today = df.iloc[row_idx] prev = df.iloc[row_idx - 1] if prev["close"] <= 0: continue gap = (today["open"] - prev["close"]) / prev["close"] if gap > MIN_GAP_DOWN or gap < MAX_GAP_DOWN: continue # Dollar volume filter (proxy) avg_dvol = df["close"].iloc[max(0, row_idx - 20):row_idx].mean() * df["volume"].iloc[max(0, row_idx - 20):row_idx].mean() if avg_dvol < MIN_AVG_DOLLAR_VOL: continue candidates.append((ticker, gap, prev["close"], today["open"])) # Sort by gap magnitude (largest gap-down first) candidates.sort(key=lambda x: x[1]) for ticker, gap, prev_close, today_open in candidates: if open_positions >= MAX_SIMULTANEOUS: break bars = load_bars(ticker, date) if bars is None or len(bars) < 10: continue # Price check if bars.iloc[0]["open"] < MIN_PRICE: continue # ORB bar: first bar at/after 9:30 ET orb_bars = bars[(bars["hour"] == 9) & (bars["minute"] >= 30) & (bars["minute"] < 35)] if len(orb_bars) == 0: continue orb = orb_bars.iloc[0] # Bearish ORB: close < open if orb["close"] >= orb["open"]: continue # RVOL check avg_daily = daily_data[ticker]["volume"].mean() if ticker in daily_data else 0 rvol = compute_rvol(bars, avg_daily) if rvol < MIN_RVOL: continue # ATR for stop sizing atr = approximate_atr(bars) if atr <= 0: continue orb_low = orb["low"] orb_high = orb["high"] entry_trigger = orb_low # short below ORB low # Stop: above ORB high (capped at ATR_STOP_MULT * ATR above entry_trigger) stop_distance = orb_high - entry_trigger max_stop_dist = ATR_STOP_MULT * atr stop_price = entry_trigger + min(stop_distance, max_stop_dist) if stop_price <= entry_trigger: continue shares = RISK_PER_TRADE / (stop_price - entry_trigger) if shares <= 0 or shares * entry_trigger > 50000: continue # Profit target: ATR * 1.5 below entry (stock continues falling) # Gap fill recovery (additional stop): if stock recovers above prev_close → exit at loss gap_recovery_stop = prev_close # stock fully recovered = worst case stop atr_target_dist = 1.5 * atr # short profit target = entry - 1.5 ATR # Scan intraday bars after ORB for entry trigger post_orb = bars[bars.index > orb_bars.index[0]] entry_price = None exit_price = None exit_reason = "no_entry" pnl = 0.0 profit_target_price: float = 0.0 for _, bar in post_orb.iterrows(): if entry_price is None: # Look for short entry: bar low breaks below ORB low if bar["low"] <= entry_trigger: entry_price = min(entry_trigger, bar["open"]) profit_target_price = entry_price - atr_target_dist # Check stop immediately (gap-through entry on same bar) if bar["high"] >= stop_price: exit_price = stop_price exit_reason = "stop" pnl = (entry_price - exit_price) * shares break continue else: # Position open — check stop first, then target, then EOD if bar["hour"] >= EXIT_HOUR and bar["minute"] >= EXIT_MIN: exit_price = bar["close"] exit_reason = "eod" break # Stop: above ORB high, or stock fully recovers gap if bar["high"] >= stop_price or bar["high"] >= gap_recovery_stop: exit_price = max(stop_price, bar["open"]) exit_reason = "stop" break # Profit target: price falls ATR * 1.5 below entry if profit_target_price > 0 and bar["low"] <= profit_target_price: exit_price = max(profit_target_price, bar["open"]) exit_reason = "target" break if entry_price is None: continue # no entry triggered if exit_price is None: # Last bar exit_price = bars.iloc[-1]["close"] exit_reason = "eod" pnl = (entry_price - exit_price) * shares # short pnl day_trades.append({ "ticker": ticker, "date": date, "gap": gap, "entry": entry_price, "exit": exit_price, "stop": stop_price, "target": prev_close, "shares": shares, "pnl": pnl, "win": pnl > 0, "exit_reason": exit_reason, }) open_positions += 1 day_pnl = sum(t["pnl"] for t in day_trades) return {"date": date, "trades": day_trades, "day_pnl": day_pnl} def main() -> None: print("=== Gap-Down ORB Short Diagnostic ===\n") with open(V23_BASELINE_RUN) as f: v23_data = json.load(f) v23_dates = [d["date"] for d in v23_data["daily_summary"]] v23_pnl = {d["date"]: d["daily_pnl"] for d in v23_data["daily_summary"]} universe = load_universe() print(f"Universe: {len(universe)} tickers") print(f"Loading daily caches...", flush=True) daily_data: dict[str, pd.DataFrame] = {} for ticker in universe: df = load_daily_cache(ticker) if df is not None and len(df) >= 2: daily_data[ticker] = df print(f"Daily cache loaded: {len(daily_data)} tickers") print(f"V23 window: {v23_dates[0]} → {v23_dates[-1]} ({len(v23_dates)} days)\n") all_results = [] for i, date in enumerate(v23_dates): result = simulate_day(date, universe, daily_data) all_results.append(result) if (i + 1) % 20 == 0: print(f" {i+1}/{len(v23_dates)} days processed...", flush=True) # Aggregate all_trades = [t for r in all_results for t in r["trades"]] total_trades = len(all_trades) wins = sum(1 for t in all_trades if t["win"]) win_rate = wins / total_trades if total_trades else 0.0 avg_win = float(np.mean([t["pnl"] for t in all_trades if t["pnl"] > 0])) if wins > 0 else 0.0 avg_loss = float(np.mean([abs(t["pnl"]) for t in all_trades if t["pnl"] <= 0])) if total_trades - wins > 0 else 0.0 total_pnl = sum(t["pnl"] for t in all_trades) exit_reasons = {} for t in all_trades: exit_reasons[t["exit_reason"]] = exit_reasons.get(t["exit_reason"], 0) + 1 # Correlation with V23 gd_daily = {r["date"]: r["day_pnl"] for r in all_results} gd_series = [gd_daily.get(d, 0.0) for d in v23_dates] v23_series = [v23_pnl.get(d, 0.0) for d in v23_dates] corr = float(np.corrcoef(gd_series, v23_series)[0, 1]) if len(v23_dates) > 1 else 0.0 trade_days = sum(1 for r in all_results if r["trades"]) print("=" * 55) print(f" Total trades: {total_trades}") print(f" Trade days: {trade_days} / {len(v23_dates)}") print(f" Win rate: {win_rate*100:.1f}% (gate: ≥42%)") print(f" Avg win: ${avg_win:.2f}") print(f" Avg loss: ${avg_loss:.2f}") if avg_loss > 0: print(f" Win/Loss ratio: {avg_win/avg_loss:.2f} (gate: ≥1.2)") print(f" Total PnL: ${total_pnl:+.2f}") print(f" Corr vs V23: {corr:.3f} (gate: ≤0.00)") print(f" Exit breakdown: {exit_reasons}") print("=" * 55) g1 = win_rate >= 0.42 g2 = (avg_win / avg_loss >= 1.2) if avg_loss > 0 else False g3 = corr <= 0.00 g4 = total_trades >= 30 print(f"\nGate G1 (WR ≥ 42%): {'PASS' if g1 else 'FAIL'}") print(f"Gate G2 (W/L ≥ 1.2): {'PASS' if g2 else 'FAIL'}") print(f"Gate G3 (corr ≤ 0.00): {'PASS' if g3 else 'FAIL'}") print(f"Gate G4 (trades ≥ 30): {'PASS' if g4 else 'FAIL'}") verdict = "PROCEED TO ENGINE BUILD" if (g1 and g2 and g3 and g4) else "ABORT — edge not confirmed" print(f"\nVERDICT: {verdict}") out = { "total_trades": total_trades, "trade_days": trade_days, "win_rate": win_rate, "avg_win": avg_win, "avg_loss": avg_loss, "total_pnl": total_pnl, "corr_v23": corr, "exit_reasons": exit_reasons, "trades": all_trades, "daily_pnl": [{"date": r["date"], "pnl": r["day_pnl"]} for r in all_results], } out_path = "runs/intraday_orb/diag_gapdown_short.json" with open(out_path, "w") as f: json.dump(out, f, indent=2, default=str) print(f"\nResults saved to {out_path}") if __name__ == "__main__": sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) main()