""" Gap-Down Catalyst Short Diagnostic — Three-Arm Comparison Hypothesis: gap-down stocks with bad-news catalysts (earnings miss, impairment, officer departure, etc.) continue lower reliably, while gap-downs with no catalyst tend to recover. Separating them lifts WR above break-even. Arms: A — no catalyst filter (baseline from diag_gapdown_short.py v1) B — require bad-news event on T or T-1 (CONTINUATION hypothesis) C — exclude bad-news event / no catalyst (RECOVERY control group) Gates (Arm B): G1: WR ≥ 50% G2: avg_win / avg_loss ≥ 1.2 G3: Pearson corr with V23 daily PnL ≤ 0.00 G4: trades ≥ 30 (sufficient sample) G5: Arm B WR − Arm C WR ≥ 5pp (filter truly separates populations) """ from __future__ import annotations import json import math import os import sys from datetime import date as _date, timedelta import numpy as np import pandas as pd import yaml CACHE_DIR = "data/cache/intraday" DAILY_CACHE_DIR = "data/cache/daily" CATALYST_CACHE_DIR = "data/cache/orb_catalyst" 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 MAX_GAP_DOWN = -0.10 MIN_AVG_DOLLAR_VOL = 25_000_000 MIN_RVOL = 1.5 ATR_STOP_MULT = 0.75 ATR_TARGET_MULT = 1.5 RISK_PER_TRADE = 500.0 MAX_SIMULTANEOUS = 3 EXIT_HOUR = 15 EXIT_MIN = 55 # Bad-news 8-K items that signal continuation (negative catalyst) BAD_NEWS_ITEMS = {"2.02", "2.06", "4.02", "1.03", "1.02"} # event_types that are definitively bad news regardless of item BAD_NEWS_EVENT_TYPES = {"earnings_result", "financial_restatement", "contract_termination"} # For item 8.01 (other_material_event) / 5.02 (management_change): keyword filter on title BAD_NEWS_ITEM_8_01_KEYWORDS = {"downgrade", "lawsuit", "investigation", "regulatory", "probe", "violation", "fraud", "warning", "recall", "suspend"} BAD_NEWS_MANAGEMENT_KEYWORDS = {"resign", "terminat", "depart", "step", "remov", "dismiss"} 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 load_catalyst_events(ticker: str, date: str) -> list[dict] | None: """Load filing events for ticker from disk cache. Returns None if uncached.""" import gzip path = f"{CATALYST_CACHE_DIR}/{ticker.upper()}.json.gz" if not os.path.exists(path): return None try: with gzip.open(path, "rt", encoding="utf-8") as fh: payload = json.load(fh) except Exception: return None coverage_start = str(payload.get("coverage_start") or "") coverage_end = str(payload.get("coverage_end") or "") if not coverage_start or not coverage_end: return None # Window: T-1 through T prev_date = str(_date.fromisoformat(date) - timedelta(days=1)) if prev_date < coverage_start or date > coverage_end: return None events = payload.get("events", []) return [ e for e in events if prev_date <= str(e.get("filing_date", ""))[:10] <= date ] def classify_bad_news(events: list[dict]) -> bool: """Return True if any event in the list is a bad-news catalyst.""" if not events: return False for e in events: item = str(e.get("item_number", "")).strip() etype = str(e.get("event_type", "")).strip().lower() title = str(e.get("title", "")).strip().lower() # Definitive bad-news items if item in BAD_NEWS_ITEMS: return True # Definitive bad-news event types if etype in BAD_NEWS_EVENT_TYPES: return True # item 8.01 with bad keywords if item == "8.01" and any(kw in title for kw in BAD_NEWS_ITEM_8_01_KEYWORDS): return True # item 5.02 management change with departure keywords if item == "5.02" and any(kw in title for kw in BAD_NEWS_MANAGEMENT_KEYWORDS): return True return False def approximate_atr(df: pd.DataFrame) -> float: return (df["high"] - df["low"]).mean() * math.sqrt(78) def simulate_trade(ticker: str, date: str, gap: float, prev_close: float, bars: pd.DataFrame) -> dict | None: """Simulate one gap-down short trade. Returns trade dict or None if no entry.""" if bars.iloc[0]["open"] < MIN_PRICE: return None orb_bars = bars[(bars["hour"] == 9) & (bars["minute"] >= 30) & (bars["minute"] < 35)] if len(orb_bars) == 0: return None orb = orb_bars.iloc[0] if orb["close"] >= orb["open"]: # must be bearish ORB return None atr = approximate_atr(bars) if atr <= 0: return None stop_dist = ATR_STOP_MULT * atr entry_trigger = orb["low"] stop_price = entry_trigger + stop_dist shares = RISK_PER_TRADE / stop_dist if shares <= 0 or shares * entry_trigger > 50000: return None post_orb = bars[bars.index > orb_bars.index[0]] entry_price = None exit_price = None exit_reason = "no_entry" profit_target: float = 0.0 for _, bar in post_orb.iterrows(): if entry_price is None: if bar["low"] <= entry_trigger: entry_price = min(entry_trigger, bar["open"]) profit_target = entry_price - ATR_TARGET_MULT * atr if bar["high"] >= stop_price: exit_price = stop_price exit_reason = "stop" break continue else: if bar["hour"] >= EXIT_HOUR and bar["minute"] >= EXIT_MIN: exit_price = bar["close"] exit_reason = "eod" break if bar["high"] >= stop_price or bar["close"] >= prev_close: exit_price = max(stop_price, bar["open"]) exit_reason = "stop" break if bar["low"] <= profit_target: exit_price = max(profit_target, bar["open"]) exit_reason = "target" break if entry_price is None: return None if exit_price is None: exit_price = bars.iloc[-1]["close"] exit_reason = "eod" pnl = (entry_price - exit_price) * shares return { "ticker": ticker, "date": date, "gap": gap, "entry": entry_price, "exit": exit_price, "pnl": pnl, "win": pnl > 0, "exit_reason": exit_reason, } def simulate_day(date: str, tickers: list[str], daily_data: dict[str, pd.DataFrame]) -> dict: """Simulate one day for all three arms.""" trades_a = [] trades_b = [] trades_c = [] open_a = open_b = open_c = 0 missing_catalyst_cache = 0 total_candidates = 0 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 or idx[0] == 0: continue row_idx = idx[0] 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 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"])) total_candidates += 1 candidates.sort(key=lambda x: x[1]) for ticker, gap, prev_close in candidates: bars = load_bars(ticker, date) if bars is None or len(bars) < 10: continue avg_daily = daily_data[ticker]["volume"].mean() first_vol = bars.iloc[0]["volume"] rvol = first_vol / (avg_daily / 78) if avg_daily > 0 else 0 if rvol < MIN_RVOL: continue # Load catalyst events for Arm B / C classification events = load_catalyst_events(ticker, date) if events is None: missing_catalyst_cache += 1 has_bad_news = None # unknown — include in Arm A but exclude from B/C else: has_bad_news = classify_bad_news(events) # Arm A: no filter if open_a < MAX_SIMULTANEOUS: trade = simulate_trade(ticker, date, gap, prev_close, bars) if trade is not None: trades_a.append(trade) open_a += 1 # Arm B: require bad-news if has_bad_news is True and open_b < MAX_SIMULTANEOUS: trade = simulate_trade(ticker, date, gap, prev_close, bars) if trade is not None: trades_b.append({**trade, "arm": "B"}) open_b += 1 # Arm C: exclude bad-news (no catalyst OR non-bad catalyst) if has_bad_news is False and open_c < MAX_SIMULTANEOUS: trade = simulate_trade(ticker, date, gap, prev_close, bars) if trade is not None: trades_c.append({**trade, "arm": "C"}) open_c += 1 return { "date": date, "trades_a": trades_a, "pnl_a": sum(t["pnl"] for t in trades_a), "trades_b": trades_b, "pnl_b": sum(t["pnl"] for t in trades_b), "trades_c": trades_c, "pnl_c": sum(t["pnl"] for t in trades_c), "missing_catalyst_cache": missing_catalyst_cache, "total_candidates": total_candidates, } def arm_stats(all_trades: list[dict], v23_dates: list[str], v23_pnl_map: dict, day_pnl_map: dict) -> dict: if not all_trades: return { "trades": 0, "win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0, "wl_ratio": 0.0, "total_pnl": 0.0, "corr_v23": 0.0, "exit_reasons": {}, } wins = [t for t in all_trades if t["win"]] losses = [t for t in all_trades if not t["win"]] avg_win = float(np.mean([t["pnl"] for t in wins])) if wins else 0.0 avg_loss = float(np.mean([abs(t["pnl"]) for t in losses])) if losses else 0.0 wl = avg_win / avg_loss if avg_loss > 0 else 0.0 total_pnl = sum(t["pnl"] for t in all_trades) exit_reasons: dict[str, int] = {} for t in all_trades: exit_reasons[t["exit_reason"]] = exit_reasons.get(t["exit_reason"], 0) + 1 gd_series = [day_pnl_map.get(d, 0.0) for d in v23_dates] v23_series = [v23_pnl_map.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 return { "trades": len(all_trades), "wins": len(wins), "win_rate": len(wins) / len(all_trades), "avg_win": avg_win, "avg_loss": avg_loss, "wl_ratio": wl, "total_pnl": total_pnl, "corr_v23": corr, "exit_reasons": exit_reasons, } def main() -> None: print("=== Gap-Down Catalyst Short Diagnostic (Three-Arm) ===\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_map = {d["date"]: d["daily_pnl"] for d in v23_data["daily_summary"]} universe = load_universe() print(f"Universe: {len(universe)} tickers") print("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") catalyst_covered = sum( 1 for t in universe if os.path.exists(f"{CATALYST_CACHE_DIR}/{t.upper()}.json.gz") ) print(f"Catalyst cache coverage: {catalyst_covered}/{len(universe)} tickers") print(f"V23 window: {v23_dates[0]} → {v23_dates[-1]} ({len(v23_dates)} days)\n") all_results = [] total_missing = 0 total_candidates = 0 for i, date in enumerate(v23_dates): result = simulate_day(date, universe, daily_data) all_results.append(result) total_missing += result["missing_catalyst_cache"] total_candidates += result["total_candidates"] if (i + 1) % 20 == 0: print(f" {i+1}/{len(v23_dates)} days processed...", flush=True) # Aggregate trades per arm all_a = [t for r in all_results for t in r["trades_a"]] all_b = [t for r in all_results for t in r["trades_b"]] all_c = [t for r in all_results for t in r["trades_c"]] # Daily PnL series per arm (for correlation) pnl_a = {r["date"]: r["pnl_a"] for r in all_results} pnl_b = {r["date"]: r["pnl_b"] for r in all_results} pnl_c = {r["date"]: r["pnl_c"] for r in all_results} sa = arm_stats(all_a, v23_dates, v23_pnl_map, pnl_a) sb = arm_stats(all_b, v23_dates, v23_pnl_map, pnl_b) sc = arm_stats(all_c, v23_dates, v23_pnl_map, pnl_c) missing_pct = total_missing / total_candidates * 100 if total_candidates else 0 print("=" * 70) print(f" Catalyst skip rate: {total_missing}/{total_candidates} candidates ({missing_pct:.1f}% uncached)") print() print(f" {'Metric':<30} {'Arm A (baseline)':<20} {'Arm B (bad-news)':<20} {'Arm C (no-catalyst)':<20}") print(f" {'-'*30} {'-'*20} {'-'*20} {'-'*20}") print(f" {'Trades':<30} {sa['trades']:<20} {sb['trades']:<20} {sc['trades']:<20}") def wr(s): return f"{s['win_rate']*100:.1f}%" def wl(s): return f"{s['wl_ratio']:.2f}" if s['wl_ratio'] else "N/A" def pnl(s): return f"${s['total_pnl']:+,.0f}" def cr(s): return f"{s['corr_v23']:.3f}" print(f" {'Win rate':<30} {wr(sa):<20} {wr(sb):<20} {wr(sc):<20}") print(f" {'Avg win':<30} ${sa['avg_win']:<19.2f} ${sb['avg_win']:<19.2f} ${sc['avg_win']:<19.2f}") print(f" {'Avg loss':<30} ${sa['avg_loss']:<19.2f} ${sb['avg_loss']:<19.2f} ${sc['avg_loss']:<19.2f}") print(f" {'Win/Loss ratio':<30} {wl(sa):<20} {wl(sb):<20} {wl(sc):<20}") print(f" {'Total PnL':<30} {pnl(sa):<20} {pnl(sb):<20} {pnl(sc):<20}") print(f" {'Corr vs V23':<30} {cr(sa):<20} {cr(sb):<20} {cr(sc):<20}") print(f" {'Exit reasons':<30} {str(sa['exit_reasons']):<20} {str(sb['exit_reasons']):<20} {str(sc['exit_reasons']):<20}") print("=" * 70) # Gate evaluation (Arm B) wr_b = sb["win_rate"] wr_c = sc["win_rate"] wl_b = sb["wl_ratio"] corr_b = sb["corr_v23"] trades_b = sb["trades"] pnl_b_val = sb["total_pnl"] g1 = wr_b >= 0.50 g2 = wl_b >= 1.2 g3 = corr_b <= 0.00 g4 = trades_b >= 30 g5 = (wr_b - wr_c) >= 0.05 if sc["trades"] > 0 else False print(f"\n Gate G1 (Arm B WR ≥ 50%): {'PASS' if g1 else 'FAIL'} ({wr_b*100:.1f}%)") print(f" Gate G2 (Arm B W/L ≥ 1.2): {'PASS' if g2 else 'FAIL'} ({wl_b:.2f})") print(f" Gate G3 (Arm B corr ≤ 0.00): {'PASS' if g3 else 'FAIL'} ({corr_b:.3f})") print(f" Gate G4 (Arm B trades ≥ 30): {'PASS' if g4 else 'FAIL'} ({trades_b})") print(f" Gate G5 (B WR − C WR ≥ 5pp): {'PASS' if g5 else 'FAIL'} ({(wr_b-wr_c)*100:.1f}pp)") passed = sum([g1, g2, g3, g4, g5]) if passed == 5: verdict = "PROCEED TO ENGINE BUILD (Phase 2)" else: verdict = f"ABORT ({passed}/5 gates passed)" print(f"\n VERDICT: {verdict}") out = { "arm_a": {**sa, "trades": all_a}, "arm_b": {**sb, "trades": all_b}, "arm_c": {**sc, "trades": all_c}, "gates": {"g1": g1, "g2": g2, "g3": g3, "g4": g4, "g5": g5, "passed": passed}, "missing_catalyst_pct": missing_pct, "daily_pnl_a": [{"date": r["date"], "pnl": r["pnl_a"]} for r in all_results], "daily_pnl_b": [{"date": r["date"], "pnl": r["pnl_b"]} for r in all_results], "daily_pnl_c": [{"date": r["date"], "pnl": r["pnl_c"]} for r in all_results], } out_path = "runs/intraday_orb/diag_gapdown_catalyst_short.json" with open(out_path, "w") as f: json.dump(out, f, indent=2, default=str) print(f"\n Results saved to {out_path}") if __name__ == "__main__": sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) main()