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.

477 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.

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