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.

284 lines
11 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.

"""
V34 Diagnostic: OBV Slope Acceleration
Tests whether SHORT-TERM OBV accumulation (5-day slope) provides signal orthogonal to
V24's existing 20-day OBV slope. Hypothesis: the most recent accumulation (last 5 days)
before a gap event captures fresher institutional positioning than the 20-day average.
Features:
obv_slope_5 : 5-day OBV accumulation slope (same formula as obv_slope_20, shorter window)
obv_slope_accel: obv_slope_5 - obv_slope_20 (positive = recent acceleration of accumulation)
The key test: does obv_slope_5 pass G5a (|ρ(slope_5, slope_20)| < 0.70)?
If highly correlated (ρ > 0.70), it's redundant with V24's signal. If orthogonal, it could
complement V24's score.
Context: V24 already uses obv_slope_20 at weight=0.05. V25-V33 all failed.
This is the final attempt using existing data (obv_slope_5 uses same formula, different window).
"""
from __future__ import annotations
import concurrent.futures
import datetime as dt
import os
import sys
from pathlib import Path
import pyarrow.parquet as pq
import yaml
from zoneinfo import ZoneInfo
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from libs.common.time_utils import trading_days_between
from libs.intraday.domain import ORBStrategyParams
from libs.intraday.features import compute_obv_slope_approx, enrich_daily_bars
from libs.intraday.orb_simulator import ORBSimulationState, run_orb_simulation_with_state
from libs.intraday.screener import orb_pre_screen_candidates
V24_CONFIG = "configs/intraday/strategies/orb_gainers_v24_quality_overlay.yaml"
UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml"
INTRADAY_CACHE_DIR = "data/cache/intraday"
LOOKBACK_DAYS = 400
_ET = ZoneInfo("America/New_York")
_MKT_OPEN = dt.time(9, 30)
_MKT_CLOSE = dt.time(16, 0)
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))
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:
return None
return {
"date": date, "open": opens[0], "high": max(highs),
"low": min(lows), "close": closes[-1], "volume": sum(vols),
}
def build_daily_bars(tickers: list[str], dates: list[str], workers: int = 8) -> dict[str, list[dict]]:
root = Path(INTRADAY_CACHE_DIR)
def _load(ticker: str) -> tuple[str, list[dict]]:
d_path = root / ticker
if not d_path.is_dir():
return ticker, []
bars: list[dict] = []
for date in dates:
p = d_path / f"{date}.parquet"
if not p.exists():
continue
bar = _build_daily_bar(p, date)
if bar and bar["close"] > 0:
bars.append(bar)
return ticker, bars
result: dict[str, list[dict]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
for ticker, bars in ex.map(_load, tickers):
if bars:
result[ticker] = bars
return result
def load_intraday_bulk(candidates: dict[str, list[str]]) -> dict[str, dict[str, list[dict]]]:
import pandas as pd
result: dict[str, dict[str, list[dict]]] = {}
for date, tickers in candidates.items():
day_bars: dict[str, list[dict]] = {}
for ticker in tickers:
p = Path(INTRADAY_CACHE_DIR) / ticker / f"{date}.parquet"
if not p.exists():
continue
try:
df = pd.read_parquet(str(p))
if not df.empty and len(df) >= 5:
day_bars[ticker] = df.to_dict("records")
except Exception:
pass
if day_bars:
result[date] = day_bars
return result
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]) -> tuple[bool, bool, bool]:
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 False, False, False
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'} (|{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])")
print(f" G5a: {'PASS' if g5a else 'FAIL'} (|ρ(feature, obv_slope_20)| = {abs(rho_obv20):.3f} [threshold 0.70])" if rho_obv20 is not None else " G5a: N/A")
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'}")
return g1, g2, g3
def main() -> None:
print("=== V34 OBV Slope Acceleration Diagnostic ===\n")
with open(V24_CONFIG) as f:
raw = yaml.safe_load(f)
params = ORBStrategyParams(**raw["orb_strategy"])
today = dt.date(2026, 4, 21)
all_td = trading_days_between(today - dt.timedelta(days=700), today)
trading_days_list = [d.isoformat() for d in all_td[-LOOKBACK_DAYS:]]
print(f"Window: {trading_days_list[0]}{trading_days_list[-1]} ({LOOKBACK_DAYS} trading days)")
extended_td = [d.isoformat() for d in all_td[-(LOOKBACK_DAYS + 35):]]
first_cal = dt.date.fromisoformat(extended_td[0])
last_cal = dt.date.fromisoformat(trading_days_list[-1])
needed_dates: list[str] = []
d = first_cal
while d <= last_cal:
needed_dates.append(d.isoformat())
d += dt.timedelta(days=1)
with open(UNIVERSE_FILE) as f:
udata = yaml.safe_load(f)
universe = udata.get("symbols", udata) if isinstance(udata, dict) else udata
if "QQQ" not in universe:
universe = list(universe) + ["QQQ"]
print(f"Universe: {len(universe)} tickers")
print(f"Building daily bars ({len(needed_dates)} calendar days)...")
daily_bars = build_daily_bars(universe, needed_dates)
print(f"Built: {len(daily_bars)} tickers")
print("Computing enrichment...")
enrichment = enrich_daily_bars(daily_bars, trading_days_list)
candidates = orb_pre_screen_candidates(
daily_bars, trading_days_list, enrichment,
min_price=params.min_price, min_atr=params.min_atr_14,
min_avg_dollar_vol=params.min_avg_dollar_volume, max_per_day=None,
)
print(f"Pre-screened: {sum(len(v) for v in candidates.values())} ticker-days")
all_intraday = load_intraday_bulk(candidates)
print("Running V24 simulation...")
state = ORBSimulationState(equity=params.initial_capital)
day_results, _ = run_orb_simulation_with_state(
all_intraday, trading_days_list, params, enrichment, state=state,
)
all_trades = [t for dr in day_results for t in dr.trades]
trades_with_r = [t for t in all_trades if getattr(t, "r_multiple_at_exit", None) is not None]
print(f"Total trades: {len(all_trades)}, with r_multiple: {len(trades_with_r)}\n")
sorted_daily: dict[str, list[dict]] = {
t: sorted(bars, key=lambda b: b["date"]) for t, bars in daily_bars.items()
}
# Compute per-trade features
slope5_vals, slope20_vals, accel_vals, r_mults = [], [], [], []
missing5, missing20, missingaccel = 0, 0, 0
for trade in trades_with_r:
ticker = trade.ticker
date = str(trade.date)[:10]
r = float(trade.r_multiple_at_exit)
bars_t = sorted_daily.get(ticker, [])
prev_bars = [b for b in bars_t if b["date"][:10] < date]
s5 = compute_obv_slope_approx(prev_bars, lookback=5) if len(prev_bars) >= 7 else None
s20 = compute_obv_slope_approx(prev_bars, lookback=20) if len(prev_bars) >= 22 else None
if s5 is None:
missing5 += 1
if s20 is None:
missing20 += 1
if s5 is None or s20 is None:
missingaccel += 1
continue
slope5_vals.append(s5)
slope20_vals.append(s20)
accel_vals.append(s5 - s20)
r_mults.append(r)
n_valid = len(r_mults)
print(f"Valid trades (both slope5 & slope20): {n_valid} / {len(trades_with_r)}")
print(f"Missing slope5: {missing5} slope20: {missing20}")
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("obv_slope_5", slope5_vals, r_mults, slope20_vals)
report_feature("obv_slope_accel (slope5 - slope20)", accel_vals, r_mults, slope20_vals)
p_5_20 = pearson(slope5_vals, slope20_vals)
print(f"\n ρ(slope_5, slope_20) = {p_5_20:.3f} (G5a threshold: 0.70)")
print("\n=== Summary ===")
if p_5_20 is not None and abs(p_5_20) >= 0.70:
print("G5a: obv_slope_5 is REDUNDANT with obv_slope_20 (ρ ≥ 0.70). Cannot add independent signal.")
print("Conclusion: V24's obv_slope_20 captures the full OBV axis. No improvement possible via window change.")
else:
print("G5a: obv_slope_5 has orthogonal component vs obv_slope_20. Check individual feature gates above.")
if __name__ == "__main__":
main()