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.

264 lines
9.6 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.

"""
V36 Diagnostic: RSI-14 Pre-Breakout Signal
Hypothesis: RSI-14 in the days before an ORB gap-up predicts follow-through quality.
Two competing sub-hypotheses:
(A) Momentum: high RSI (>60) = confirmed uptrend, clean breakout. Positive Pearson.
(B) Mean-reversion: high RSI = overbought, gap sells off. Negative Pearson.
(C) Sweet spot: moderate RSI (40-60) = breakout from accumulation, not extended.
Features:
rsi_14 : raw RSI-14 [0, 100] on last prev_bar
rsi_momentum: (RSI - 50) / 50, centered at midline [-1, +1]
Source: V24 400d run JSON, daily bars from parquet cache.
Uses Cutler's RSI (simple average, same formula as libs/features/market_features.py).
"""
from __future__ import annotations
import concurrent.futures
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)
_MKT_CLOSE = dt.time(16, 0)
INTRADAY_CACHE_DIR = "data/cache/intraday"
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 _build_daily_bar_from_parquet(path: Path, date: str) -> dict | None:
try:
table = pq.read_table(str(path))
rows = table.to_pydict()
except Exception:
return None
opens, highs, lows, closes, vols = [], [], [], [], []
for i, ts_raw in enumerate(rows.get("timestamp", [])):
try:
ts = _parse_ts(ts_raw)
except Exception:
continue
if _MKT_OPEN <= ts.time() < _MKT_CLOSE:
opens.append(float(rows["open"][i] or 0))
highs.append(float(rows["high"][i] or 0))
lows.append(float(rows["low"][i] or 0))
closes.append(float(rows["close"][i] or 0))
vols.append(float(rows["volume"][i] or 0))
if not opens or closes[-1] <= 0:
return None
return {
"date": date, "open": opens[0], "high": max(highs),
"low": min(lows), "close": closes[-1], "volume": sum(vols),
}
def load_ticker_daily_bars(ticker: str, needed_dates: list[str]) -> list[dict]:
root = Path(INTRADAY_CACHE_DIR) / ticker
if not root.is_dir():
return []
bars = []
for date in needed_dates:
p = root / f"{date}.parquet"
if not p.exists():
continue
bar = _build_daily_bar_from_parquet(p, date)
if bar:
bars.append(bar)
return sorted(bars, key=lambda b: b["date"])
def compute_rsi_14(bars: list[dict], period: int = 14) -> float | None:
"""Cutler's RSI from daily close prices. bars sorted oldest→newest."""
if len(bars) < period + 2:
return None
tail = bars[-(period + 1):]
gains, losses = [], []
for i in range(period):
change = tail[i + 1]["close"] - tail[i]["close"]
if change >= 0:
gains.append(change)
losses.append(0.0)
else:
gains.append(0.0)
losses.append(abs(change))
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
return 100.0 - (100.0 / (1.0 + avg_gain / avg_loss))
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 report_feature(label: str, vals: list[float], rs: list[float], obv20: list[float] | None = None) -> None:
n = len(vals)
p = pearson(vals, rs)
ts = tercile_stats(vals, rs)
rho_obv20 = pearson(vals, obv20) if obv20 else None
if not ts or p is None:
print(f" {label}: insufficient data")
return
low, mid, high = ts["low"], ts["mid"], ts["high"]
avg_r_gap = abs(high["avg_r"] - low["avg_r"])
wr_gap = abs(high["wr"] - low["wr"])
g1 = abs(p) >= 0.07 and n >= 120
g2 = avg_r_gap >= 0.30
g3 = wr_gap >= 0.05
g5a = rho_obv20 is None or abs(rho_obv20) < 0.70
print(f"\n [{label}] n={n} Pearson={p:+.3f}")
print(f" G1: {'PASS' if g1 else 'FAIL'} (|{abs(p):.3f}| {'' if abs(p)>=0.07 else '<'} 0.07, n={n} {'' if n>=120 else '<'} 120)")
print(f" G2: {'PASS' if g2 else 'FAIL'} (avg_R gap = {avg_r_gap:.3f}R [threshold 0.30R])")
print(f" G3: {'PASS' if g3 else 'FAIL'} (WR gap = {wr_gap*100:.1f}pp [threshold 5pp])")
if rho_obv20 is not None:
print(f" G5a: {'PASS' if g5a else 'FAIL'} (|ρ(feature, obv_slope_20)| = {abs(rho_obv20):.3f} [threshold 0.70])")
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']}")
all_pass = g1 and g2 and g3 and g5a
print(f"{'ALL GATES PASS ✓' if all_pass else 'FAIL'}")
def main() -> None:
print("=== V36 RSI-14 Pre-Breakout 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")
trade_records = [
{"ticker": t["ticker"], "date": t["date"][:10],
"r_multiple": float(t["r_multiple_at_exit"])}
for t in trades
if t.get("r_multiple_at_exit") is not None
]
print(f"Trades with r_multiple: {len(trade_records)}")
# Gather all unique tickers and dates needed (extended window for RSI lookback)
tickers_needed = sorted(set(r["ticker"] for r in trade_records))
dates_by_ticker: dict[str, set[str]] = {t: set() for t in tickers_needed}
for rec in trade_records:
dates_by_ticker[rec["ticker"]].add(rec["date"])
print(f"Loading daily bars for {len(tickers_needed)} tickers...")
# Determine needed calendar range per ticker (need 30+ prior trading days)
import datetime as dt
min_date = min(r["date"] for r in trade_records)
max_date = max(r["date"] for r in trade_records)
# Build full calendar range with buffer
start_cal = (dt.date.fromisoformat(min_date) - dt.timedelta(days=90)).isoformat()
all_dates = []
d = dt.date.fromisoformat(start_cal)
end_d = dt.date.fromisoformat(max_date)
while d <= end_d:
all_dates.append(d.isoformat())
d += dt.timedelta(days=1)
def _load(ticker: str) -> tuple[str, list[dict]]:
return ticker, load_ticker_daily_bars(ticker, all_dates)
ticker_bars: dict[str, list[dict]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
for ticker, bars in ex.map(_load, tickers_needed):
if bars:
ticker_bars[ticker] = bars
print(f"Loaded bars for {len(ticker_bars)} / {len(tickers_needed)} tickers\n")
# Compute features per trade
rsi_vals, rsi_mom_vals, obv20_vals, r_mults = [], [], [], []
missing = 0
for rec in trade_records:
ticker = rec["ticker"]
date = rec["date"]
r = rec["r_multiple"]
bars = ticker_bars.get(ticker, [])
prev_bars = [b for b in bars if b["date"] < date]
rsi = compute_rsi_14(prev_bars, period=14) if len(prev_bars) >= 16 else None
if rsi is None:
missing += 1
continue
# OBV slope 20d for redundancy check
from libs.intraday.features import compute_obv_slope_approx
obv20 = compute_obv_slope_approx(prev_bars, lookback=20) if len(prev_bars) >= 22 else None
rsi_vals.append(rsi)
rsi_mom_vals.append((rsi - 50.0) / 50.0)
obv20_vals.append(obv20 if obv20 is not None else 0.0)
r_mults.append(r)
n_valid = len(r_mults)
print(f"Valid trades (RSI computable): {n_valid} / {len(trade_records)} (missing: {missing})")
print("\n" + "=" * 60)
print("GATE RESULTS (G1: |P|≥0.07 & n≥120; G2: avg_R≥0.30R; G3: WR≥5pp; G5a: ρ<0.70 vs obv_slope_20)")
report_feature("rsi_14", rsi_vals, r_mults, obv20_vals)
report_feature("rsi_momentum (RSI-50)/50", rsi_mom_vals, r_mults, obv20_vals)
# Distribution stats
mean_rsi = sum(rsi_vals) / len(rsi_vals) if rsi_vals else 0
print(f"\n RSI distribution: mean={mean_rsi:.1f} min={min(rsi_vals):.1f} max={max(rsi_vals):.1f}")
print("\n=== Summary ===")
p = pearson(rsi_vals, r_mults)
if p is not None:
direction = "Momentum (high RSI = better)" if p > 0 else "Mean-reversion (low RSI = better)"
print(f"Signal direction: {direction} (Pearson={p:+.3f})")
print("Context: V24's only passing axis: OBV-slope G2=0.394R. Target: G2 ≥ 0.30R.")
if __name__ == "__main__":
main()