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.

276 lines
10 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.

"""
V37 Diagnostic: Bollinger Band %B and BB Width pre-breakout signal
Hypothesis A (compression): stocks with narrowing Bollinger Bands (low BB width) before a
gap-up may be in a coiling phase — explosive breakout, better follow-through.
Hypothesis B (position): stocks near the upper band (%B > 0.8) have confirmed momentum
and continue higher after the gap.
Features:
bb_pct_b : (close - lower_band) / (upper_band - lower_band), last prev_bar [0-1+]
bb_width : (upper_band - lower_band) / close — normalized band width (compression)
bb_width_pct: percentile rank of bb_width vs own 60d history (0=tightest compression)
Source: V24 400d run JSON + daily parquet cache.
"""
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(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))
closes.append(float(rows["close"][i] or 0))
if not closes or closes[-1] <= 0:
return None
return {"date": date, "open": opens[0] if opens else 0, "close": closes[-1]}
def load_ticker_bars(ticker: str, all_dates: list[str]) -> list[dict]:
root = Path(INTRADAY_CACHE_DIR) / ticker
if not root.is_dir():
return []
bars = []
for date in all_dates:
p = root / f"{date}.parquet"
if not p.exists():
continue
bar = _build_daily_bar(p, date)
if bar and bar["close"] > 0:
bars.append(bar)
return sorted(bars, key=lambda b: b["date"])
def compute_bb(bars: list[dict], window: int = 20, num_std: float = 2.0) -> tuple[float, float] | None:
"""Returns (pct_b, width) from last `window` bars. bars sorted oldest→newest."""
if len(bars) < window:
return None
tail = bars[-window:]
closes = [b["close"] for b in tail]
mean = sum(closes) / window
variance = sum((c - mean) ** 2 for c in closes) / window
std = variance ** 0.5
if std == 0:
return None
upper = mean + num_std * std
lower = mean - num_std * std
last_close = closes[-1]
band_width = upper - lower
if band_width <= 0:
return None
pct_b = (last_close - lower) / band_width
width = band_width / last_close # normalized
return pct_b, width
def compute_bb_width_percentile(bars: list[dict], window: int = 20, history: int = 60) -> float | None:
"""Percentile rank of current BB width vs own last `history` days."""
if len(bars) < window + history:
return None
widths = []
for i in range(history):
end_idx = len(bars) - history + i + 1
tail = bars[max(0, end_idx - window):end_idx]
if len(tail) < window:
continue
closes = [b["close"] for b in tail]
mean = sum(closes) / len(closes)
std = (sum((c - mean) ** 2 for c in closes) / len(closes)) ** 0.5
if std == 0:
continue
widths.append((mean + 2 * std - (mean - 2 * std)) / closes[-1])
if len(widths) < 20:
return None
current = widths[-1]
rank = sum(1 for w in widths if w <= current) / len(widths)
return rank
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 n={n}")
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})")
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']}")
print(f"{'ALL GATES PASS ✓' if (g1 and g2 and g3 and g5a) else 'FAIL'}")
def main() -> None:
print("=== V37 BB %B and Width 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}% DD: {m.get('max_drawdown_pct', 0)*100:.2f}% 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)}")
tickers_needed = sorted(set(r["ticker"] for r in trade_records))
min_date = min(r["date"] for r in trade_records)
max_date = max(r["date"] for r in trade_records)
start_cal = (dt.date.fromisoformat(min_date) - dt.timedelta(days=150)).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)
print(f"Loading bars for {len(tickers_needed)} tickers ({start_cal}{max_date})...")
ticker_bars: dict[str, list[dict]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
def _load(ticker: str) -> tuple[str, list[dict]]:
return ticker, load_ticker_bars(ticker, all_dates)
for ticker, bars in ex.map(_load, tickers_needed):
if bars:
ticker_bars[ticker] = bars
print(f"Loaded: {len(ticker_bars)} / {len(tickers_needed)} tickers\n")
pctb_vals, width_vals, width_pct_vals, obv20_vals, r_mults = [], [], [], [], []
missing_bb, missing_wp = 0, 0
from libs.intraday.features import compute_obv_slope_approx
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]
bb = compute_bb(prev_bars, window=20) if len(prev_bars) >= 20 else None
if bb is None:
missing_bb += 1
continue
pct_b, width = bb
wp = compute_bb_width_percentile(prev_bars, window=20, history=60)
if wp is None:
missing_wp += 1
obv20 = compute_obv_slope_approx(prev_bars, lookback=20) if len(prev_bars) >= 22 else None
pctb_vals.append(pct_b)
width_vals.append(width)
if wp is not None:
width_pct_vals.append(wp)
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 (BB computable): {n_valid} / {len(trade_records)}")
print(f"Missing BB: {missing_bb} Missing width_pct: {missing_wp} Valid width_pct: {len(width_pct_vals)}")
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)")
obv20_aligned_pctb = obv20_vals[:len(pctb_vals)]
report_feature("bb_pct_b", pctb_vals, r_mults, obv20_aligned_pctb)
report_feature("bb_width (compression)", width_vals, r_mults, obv20_aligned_pctb)
if len(width_pct_vals) >= 120:
report_feature("bb_width_percentile_60d", width_pct_vals, r_mults[:len(width_pct_vals)], obv20_aligned_pctb[:len(width_pct_vals)])
else:
print(f"\n bb_width_percentile_60d: insufficient n={len(width_pct_vals)} (need 120)")
p_pctb_width = pearson(pctb_vals, width_vals)
print(f"\n Inter-feature: ρ(bb_pct_b, bb_width) = {p_pctb_width:.3f}")
mean_pctb = sum(pctb_vals)/len(pctb_vals) if pctb_vals else 0
print(f" %B distribution: mean={mean_pctb:.2f} (0=lower, 0.5=mid, 1=upper, >1=above band)")
print("\n=== Summary ===")
print(f"G2 target: ≥ 0.30R. OBV-slope = 0.394R (only axis that cleared).")
if __name__ == "__main__":
main()