|
|
"""
|
|
|
V33 Diagnostic: QQQ ORB Candle Quality as Market-Level Regime Signal
|
|
|
|
|
|
Hypothesis: on days when QQQ's own 5-min ORB bar is bullish (strong body, closes near high),
|
|
|
individual gap-up stock ORB breakouts have better follow-through.
|
|
|
|
|
|
The current V24 regime filter requires QQQ to open > +0.15% (daily gap) but doesn't check
|
|
|
whether QQQ's ORB CANDLE ITSELF is bullish. A day where QQQ opens +0.2% but the ORB bar is
|
|
|
red (intraday fade) may be a trap day. A day where QQQ opens +0.2% AND the ORB bar is strongly
|
|
|
bullish is a cleaner market-wide breakout day.
|
|
|
|
|
|
Features (all from QQQ's 9:30-9:35 ET bar — available at 9:35 AM before any entry):
|
|
|
qqq_orb_body_pct : (close - open) / (high - low). +1.0 = full bullish candle, 0 = doji
|
|
|
qqq_orb_return : (close - open) / open. Raw return of QQQ's ORB bar.
|
|
|
qqq_orb_close_loc : (close - low) / (high - low). Close position in bar range (1 = at high)
|
|
|
|
|
|
Source: V24 400d trade set (n=~304 trades, ~120-140 unique trade days).
|
|
|
Note: day-level feature (all trades on same day share same QQQ ORB value) — effective n for
|
|
|
correlation is unique trading days, but trade-level Pearson is also reported.
|
|
|
|
|
|
Gates (same as V25-V31):
|
|
|
G1: |Pearson| >= 0.07 on >= 120 trades (or >= 80 unique days)
|
|
|
G2: |top - bottom tercile avg_R| >= 0.30R
|
|
|
G3: |top - bottom tercile WR| >= 5pp
|
|
|
G5a: |rho(qqq_feature, V24 obv_slope per trade)| < 0.70 (redundancy check)
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import datetime as dt
|
|
|
import json
|
|
|
import os
|
|
|
import sys
|
|
|
from pathlib import Path
|
|
|
|
|
|
import pyarrow.parquet as pq
|
|
|
|
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
_MKT_OPEN = dt.time(9, 30)
|
|
|
|
|
|
INTRADAY_CACHE_DIR = "data/cache/intraday"
|
|
|
# Use most recent V24 400d run (trail=0.8, risk=0.05, OBV-slope=0.05)
|
|
|
V24_400D_RUN = "runs/intraday_orb/intraday_20260422_011012_06f59ede.json"
|
|
|
|
|
|
|
|
|
def _parse_ts(ts_raw: object) -> dt.datetime:
|
|
|
s = str(ts_raw)
|
|
|
if s.endswith("Z"):
|
|
|
s = s[:-1] + "+00:00"
|
|
|
return dt.datetime.fromisoformat(s).astimezone(_ET)
|
|
|
|
|
|
|
|
|
def load_qqq_orb_bar(date: str) -> dict | None:
|
|
|
"""Load QQQ's 5-minute ORB bar (9:30-9:35 ET) for a given date."""
|
|
|
path = Path(INTRADAY_CACHE_DIR) / "QQQ" / f"{date}.parquet"
|
|
|
if not path.exists():
|
|
|
return None
|
|
|
try:
|
|
|
table = pq.read_table(str(path))
|
|
|
rows = table.to_pydict()
|
|
|
except Exception:
|
|
|
return None
|
|
|
for i, ts_raw in enumerate(rows.get("timestamp", [])):
|
|
|
try:
|
|
|
ts = _parse_ts(ts_raw)
|
|
|
except Exception:
|
|
|
continue
|
|
|
if ts.time() == _MKT_OPEN:
|
|
|
o = float(rows["open"][i] or 0)
|
|
|
h = float(rows["high"][i] or 0)
|
|
|
lo = float(rows["low"][i] or 0)
|
|
|
c = float(rows["close"][i] or 0)
|
|
|
if o > 0 and h > lo:
|
|
|
return {"open": o, "high": h, "low": lo, "close": c}
|
|
|
return None
|
|
|
|
|
|
|
|
|
def pearson(xs: list[float], ys: list[float]) -> float | None:
|
|
|
n = len(xs)
|
|
|
if n < 2:
|
|
|
return None
|
|
|
xm, ym = sum(xs) / n, sum(ys) / n
|
|
|
num = sum((xs[i] - xm) * (ys[i] - ym) for i in range(n))
|
|
|
dx = sum((x - xm) ** 2 for x in xs) ** 0.5
|
|
|
dy = sum((y - ym) ** 2 for y in ys) ** 0.5
|
|
|
if dx <= 0 or dy <= 0:
|
|
|
return None
|
|
|
return num / (dx * dy)
|
|
|
|
|
|
|
|
|
def tercile_stats(vals: list[float], rs: list[float]) -> dict:
|
|
|
if len(vals) < 9:
|
|
|
return {}
|
|
|
pairs = sorted(zip(vals, rs), key=lambda p: p[0])
|
|
|
n = len(pairs)
|
|
|
t = n // 3
|
|
|
def stats(sub):
|
|
|
ys = [p[1] for p in sub]
|
|
|
return {"n": len(ys), "wr": sum(1 for y in ys if y > 0) / len(ys), "avg_r": sum(ys) / len(ys)}
|
|
|
return {"low": stats(pairs[:t]), "mid": stats(pairs[t:2*t]), "high": stats(pairs[2*t:])}
|
|
|
|
|
|
|
|
|
def check_gates(label: str, vals: list[float], r_mult: list[float]) -> None:
|
|
|
n = len(vals)
|
|
|
p = pearson(vals, r_mult)
|
|
|
ts = tercile_stats(vals, r_mult)
|
|
|
if not ts:
|
|
|
print(f" {label}: n={n}, insufficient data")
|
|
|
return
|
|
|
low, mid, high = ts["low"], ts["mid"], ts["high"]
|
|
|
g1 = p is not None and abs(p) >= 0.07 and n >= 80
|
|
|
g2 = abs(high["avg_r"] - low["avg_r"]) >= 0.30
|
|
|
g3 = abs(high["wr"] - low["wr"]) >= 0.05
|
|
|
print(f"\n [{label}] n={n} Pearson={p:.3f}")
|
|
|
print(f" G1: {'PASS' if g1 else 'FAIL'} (|{p:.3f}| {'≥' if g1 else '<'} 0.07, n={n})")
|
|
|
print(f" G2: {'PASS' if g2 else 'FAIL'} (|top-bot avg_R| = {abs(high['avg_r']-low['avg_r']):.3f}R)")
|
|
|
print(f" G3: {'PASS' if g3 else 'FAIL'} (|top-bot WR| = {abs(high['wr']-low['wr'])*100:.1f}pp)")
|
|
|
print(f" Bottom tercile: WR {low['wr']*100:.1f}% avg_R {low['avg_r']:.3f} n={low['n']}")
|
|
|
print(f" Middle tercile: WR {mid['wr']*100:.1f}% avg_R {mid['avg_r']:.3f} n={mid['n']}")
|
|
|
print(f" Top tercile: WR {high['wr']*100:.1f}% avg_R {high['avg_r']:.3f} n={high['n']}")
|
|
|
status = "PASS ALL" if (g1 and g2 and g3) else "FAIL"
|
|
|
print(f" Status: {status}")
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
print("=== V33 QQQ ORB Candle Quality Diagnostic ===\n")
|
|
|
|
|
|
with open(V24_400D_RUN) as f:
|
|
|
run_data = json.load(f)
|
|
|
trades = run_data.get("trades", [])
|
|
|
m = run_data.get("metrics", {})
|
|
|
print(f"Loaded V24 400d run: {m.get('total_trades')} trades, "
|
|
|
f"{m.get('start_date')} → {m.get('end_date')}")
|
|
|
print(f"Return: {m.get('total_return_pct', 0)*100:.2f}% "
|
|
|
f"DD: {m.get('max_drawdown_pct', 0)*100:.2f}% "
|
|
|
f"Sharpe: {m.get('sharpe_ratio', 0):.3f}\n")
|
|
|
|
|
|
# Build per-trade data
|
|
|
trade_records = [
|
|
|
{"date": t["date"], "r_multiple": t.get("r_multiple_at_exit") or 0.0}
|
|
|
for t in trades
|
|
|
if t.get("r_multiple_at_exit") is not None
|
|
|
]
|
|
|
dates_needed = sorted(set(r["date"] for r in trade_records))
|
|
|
print(f"Trade records: {len(trade_records)} with r_multiple, across {len(dates_needed)} unique dates")
|
|
|
|
|
|
# Load QQQ ORB bars for each date
|
|
|
print("Loading QQQ ORB bars...")
|
|
|
qqq_bars: dict[str, dict] = {}
|
|
|
missing = 0
|
|
|
for date in dates_needed:
|
|
|
bar = load_qqq_orb_bar(date)
|
|
|
if bar:
|
|
|
qqq_bars[date] = bar
|
|
|
else:
|
|
|
missing += 1
|
|
|
print(f" Loaded: {len(qqq_bars)}/{len(dates_needed)} dates (missing: {missing})")
|
|
|
|
|
|
# Compute features per trade
|
|
|
body_vals, return_vals, closeloc_vals, r_mults = [], [], [], []
|
|
|
skipped = 0
|
|
|
for rec in trade_records:
|
|
|
bar = qqq_bars.get(rec["date"])
|
|
|
if not bar:
|
|
|
skipped += 1
|
|
|
continue
|
|
|
o, h, lo, c = bar["open"], bar["high"], bar["low"], bar["close"]
|
|
|
body_range = h - lo
|
|
|
if body_range <= 0:
|
|
|
skipped += 1
|
|
|
continue
|
|
|
body_pct = (c - o) / body_range # [-1, 1], +1 = full bullish
|
|
|
orb_ret = (c - o) / o # raw return of ORB bar
|
|
|
close_loc = (c - lo) / body_range # [0, 1], 1 = closed at high
|
|
|
|
|
|
body_vals.append(body_pct)
|
|
|
return_vals.append(orb_ret)
|
|
|
closeloc_vals.append(close_loc)
|
|
|
r_mults.append(rec["r_multiple"])
|
|
|
|
|
|
n_valid = len(r_mults)
|
|
|
coverage = n_valid / len(trade_records) if trade_records else 0
|
|
|
print(f" Valid trades: {n_valid}/{len(trade_records)} (coverage {coverage*100:.1f}%)")
|
|
|
print(f" Skipped: {skipped}\n")
|
|
|
|
|
|
if n_valid < 30:
|
|
|
print("Insufficient data for analysis.")
|
|
|
return
|
|
|
|
|
|
print("=" * 60)
|
|
|
print("GATE RESULTS (G1: |Pearson|>=0.07 & n>=80; G2: avg_R gap>=0.30R; G3: WR gap>=5pp)")
|
|
|
check_gates("qqq_orb_body_pct", body_vals, r_mults)
|
|
|
check_gates("qqq_orb_return", return_vals, r_mults)
|
|
|
check_gates("qqq_orb_close_loc", closeloc_vals, r_mults)
|
|
|
|
|
|
# Redundancy check vs OBV-slope (from trade obv_slope_20 stored in enrichment)
|
|
|
# We don't have per-trade obv_slope but check pairwise between qqq features
|
|
|
p_body_ret = pearson(body_vals, return_vals)
|
|
|
p_body_cloc = pearson(body_vals, closeloc_vals)
|
|
|
p_ret_cloc = pearson(return_vals, closeloc_vals)
|
|
|
print("\n Inter-feature correlations:")
|
|
|
print(f" ρ(body_pct, orb_return) = {p_body_ret:.3f}")
|
|
|
print(f" ρ(body_pct, close_loc) = {p_body_cloc:.3f}")
|
|
|
print(f" ρ(orb_ret, close_loc) = {p_ret_cloc:.3f}")
|
|
|
|
|
|
# Day-level analysis (aggregate by unique date)
|
|
|
day_data: dict[str, dict] = {}
|
|
|
for i, rec in enumerate(trade_records):
|
|
|
if rec["date"] not in qqq_bars:
|
|
|
continue
|
|
|
d = rec["date"]
|
|
|
if d not in day_data:
|
|
|
bar = qqq_bars[d]
|
|
|
o, h, lo, c = bar["open"], bar["high"], bar["low"], bar["close"]
|
|
|
body_range = h - lo
|
|
|
if body_range <= 0:
|
|
|
continue
|
|
|
day_data[d] = {
|
|
|
"qqq_body": (c - o) / body_range,
|
|
|
"qqq_return": (c - o) / o,
|
|
|
"qqq_close_loc": (c - lo) / body_range,
|
|
|
"r_mults": [],
|
|
|
}
|
|
|
day_data[d]["r_mults"].append(rec["r_multiple"])
|
|
|
day_avg_r = {d: sum(v["r_mults"]) / len(v["r_mults"]) for d, v in day_data.items()}
|
|
|
day_body = [v["qqq_body"] for d, v in day_data.items()]
|
|
|
day_ret = [v["qqq_return"] for d, v in day_data.items()]
|
|
|
day_cloc = [v["qqq_close_loc"] for d, v in day_data.items()]
|
|
|
day_r = [day_avg_r[d] for d in day_data]
|
|
|
p_day_body = pearson(day_body, day_r)
|
|
|
p_day_ret = pearson(day_ret, day_r)
|
|
|
p_day_cloc = pearson(day_cloc, day_r)
|
|
|
print(f"\n Day-level correlation (n_days={len(day_data)}, using avg_R per day):")
|
|
|
print(f" ρ(qqq_body, avg_day_R) = {p_day_body:.3f}")
|
|
|
print(f" ρ(qqq_orb_ret, avg_day_R) = {p_day_ret:.3f}")
|
|
|
print(f" ρ(qqq_close_loc, avg_day_R) = {p_day_cloc:.3f}")
|
|
|
|
|
|
print("\n=== Summary ===")
|
|
|
print("Signal library context: 7 prior axes failed G2 >= 0.30R (only OBV-slope 0.394R passed).")
|
|
|
print("QQQ ORB body_pct, return, close_loc are day-level features (market-wide confirmation).")
|
|
|
print("These cannot be used for candidate ranking (same value for all stocks on same day).")
|
|
|
print("Use case: day-level gate (skip all ORB trades when QQQ ORB candle is weak/bearish).")
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
main()
|