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.

508 lines
20 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.

"""
V23 Quality Feature Diagnostic: Hurst / OU-θ / OBV-Slope
Hypothesis: Daily-prior return-series statistics (Hurst exponent, OU mean-reversion
speed, OBV accumulation slope) carry incremental per-trade edge when overlaid on V23's
candidate set — similar to how entropy_20d works.
Method:
1. Run V23 simulation over 200d window (baseline, full regime filter active)
2. For each completed trade, compute three new features on prev_bars at trade date:
- hurst_60 : Hurst exponent (R/S, 60-day lookback) — H>0.5 = trending
- ou_theta_60 : OU mean-reversion speed (AR(1) β→-ln(β), 60d) — high = fast reversion
- obv_slope_20: OBV accumulation slope (normalized, 20d) — positive = accumulation
3. Report per-feature:
- Pearson correlation with r_multiple_at_exit
- Tercile-split WR, avg_pnl, sample counts
Gates (for promotion to Phase 2):
G1: |Pearson| ≥ 0.07 with r_multiple_at_exit
G2: top-tercile avg_r bottom-tercile avg_r ≥ 0.3R
G3: top-tercile WR ≥ bottom-tercile WR + 5pp
"""
from __future__ import annotations
import datetime as dt
import json
import math
import os
import sys
from concurrent.futures import ThreadPoolExecutor
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 enrich_daily_bars
from libs.intraday.orb_simulator import ORBSimulationState, run_orb_simulation_with_state
from libs.intraday.screener import orb_pre_screen_candidates
# ── Config ─────────────────────────────────────────────────────────────────
V23_CONFIG = "configs/intraday/strategies/orb_gainers_v23.yaml"
UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml"
INTRADAY_CACHE_DIR = "data/cache/intraday"
LOOKBACK_DAYS = 200
FEATURE_LOOKBACK_TRADING = 70 # need 60 trading days; add buffer → 70
_ET = ZoneInfo("America/New_York")
_MKT_OPEN = dt.time(9, 30)
_MKT_CLOSE = dt.time(16, 0)
# ── Feature Computations (ported from libs/features/market_features.py) ────
def compute_hurst_approx(prev_bars: list[dict], lookback: int = 60) -> float | None:
"""Hurst exponent via R/S rescaled-range. H>0.5 trending, H<0.5 mean-reverting."""
if len(prev_bars) < lookback + 2:
return None
sorted_bars = sorted(prev_bars, key=lambda b: b["date"])
recent = sorted_bars[-(lookback + 1):]
returns: list[float] = []
for i in range(1, len(recent)):
pc = recent[i - 1].get("close")
cc = recent[i].get("close")
if not pc or not cc or pc <= 0:
continue
returns.append((cc - pc) / pc)
if len(returns) < 30:
return None
def rs_stat(series: list[float]) -> float:
n = len(series)
mean = sum(series) / n
devs = [x - mean for x in series]
cumdev: list[float] = []
s = 0.0
for d in devs:
s += d
cumdev.append(s)
r = max(cumdev) - min(cumdev)
std = (sum(d ** 2 for d in devs) / n) ** 0.5
return r / std if std > 0 else 0.0
window_sizes = [w for w in [8, 12, 16, 24, 32] if w <= len(returns) // 2]
if len(window_sizes) < 2:
return None
log_n: list[float] = []
log_rs: list[float] = []
for w in window_sizes:
rs_vals = [
rs_stat(returns[start:start + w])
for start in range(0, len(returns) - w + 1, w)
if len(returns[start:start + w]) == w
]
if rs_vals:
avg_rs = sum(rs_vals) / len(rs_vals)
if avg_rs > 0:
log_n.append(math.log(w))
log_rs.append(math.log(avg_rs))
if len(log_n) < 2:
return None
n = len(log_n)
xm = sum(log_n) / n
ym = sum(log_rs) / n
num = sum((log_n[i] - xm) * (log_rs[i] - ym) for i in range(n))
den = sum((log_n[i] - xm) ** 2 for i in range(n))
if den <= 0:
return 0.5
return num / den
def compute_ou_theta_approx(prev_bars: list[dict], lookback: int = 60) -> float | None:
"""OU mean-reversion speed θ via AR(1). θ = -ln(β). High = fast reversion."""
if len(prev_bars) < lookback + 2:
return None
sorted_bars = sorted(prev_bars, key=lambda b: b["date"])
recent = sorted_bars[-(lookback + 1):]
log_prices: list[float] = []
for bar in recent:
c = bar.get("close")
if not c or c <= 0:
return None
log_prices.append(math.log(c))
if len(log_prices) < lookback:
return None
y = log_prices[1:]
x = log_prices[:-1]
n = len(y)
xm = sum(x) / n
ym = sum(y) / n
num = sum((x[i] - xm) * (y[i] - ym) for i in range(n))
den = sum((x[i] - xm) ** 2 for i in range(n))
if den <= 0:
return None
beta = num / den
if beta <= 0 or beta >= 1.0:
return 0.0
return -math.log(beta)
def compute_obv_slope_approx(prev_bars: list[dict], lookback: int = 20) -> float | None:
"""OBV accumulation slope, normalized by avg_volume. Positive = accumulation."""
if len(prev_bars) < lookback + 2:
return None
sorted_bars = sorted(prev_bars, key=lambda b: b["date"])
recent = sorted_bars[-(lookback + 1):]
obv_series = [0.0]
total_vol = 0.0
for i in range(1, len(recent)):
pc = recent[i - 1].get("close")
cc = recent[i].get("close")
vol = float(recent[i].get("volume") or 0)
total_vol += vol
if not pc or pc <= 0:
continue
if cc > pc:
obv_series.append(obv_series[-1] + vol)
elif cc < pc:
obv_series.append(obv_series[-1] - vol)
else:
obv_series.append(obv_series[-1])
avg_vol = total_vol / lookback if lookback > 0 else 1.0
if avg_vol <= 0:
return None
n = len(obv_series)
xm = (n - 1) / 2.0
ym = sum(obv_series) / n
num = sum((i - xm) * (obv_series[i] - ym) for i in range(n))
den = sum((i - xm) ** 2 for i in range(n))
if den <= 0:
return 0.0
return (num / den) / avg_vol
# ── Daily Bar Builder ────────────────────────────────────────────────────────
def _build_daily_bar_from_intraday(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:
if ts_raw.endswith("Z"):
ts_raw = ts_raw[:-1] + "+00:00"
ts = dt.datetime.fromisoformat(ts_raw).astimezone(_ET)
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_from_intraday(p, date)
if bar and bar["close"] > 0:
bars.append(bar)
return ticker, bars
result: dict[str, list[dict]] = {}
with 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
# ── Stats Helpers ─────────────────────────────────────────────────────────────
def pearson(xs: list[float], ys: list[float]) -> float | None:
if len(xs) != len(ys) or len(xs) < 2:
return None
n = len(xs)
xm = sum(xs) / n
ym = 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], outcomes_r: list[float]) -> dict:
if len(vals) < 6:
return {}
pairs = sorted(zip(vals, outcomes_r), key=lambda p: p[0])
n = len(pairs)
t = n // 3
def stats(pairs_sub):
ys = [p[1] for p in pairs_sub]
wins = [y for y in ys if y > 0]
wr = len(wins) / len(ys) if ys else 0.0
avg = sum(ys) / len(ys) if ys else 0.0
return {"n": len(ys), "wr": wr, "avg_r": avg}
return {
"low": stats(pairs[:t]),
"mid": stats(pairs[t:2*t]),
"high": stats(pairs[2*t:]),
}
# ── Main ────────────────────────────────────────────────────────────────────
def main() -> None:
print("=== V23 Quality Feature Diagnostic (Hurst / OU-θ / OBV-Slope) ===\n")
# 1. Load V23 config
with open(V23_CONFIG) as f:
raw = yaml.safe_load(f)
params = ORBStrategyParams(**raw["orb_strategy"])
print(f"V23 regime threshold: {params.market_regime_spy_threshold}")
# 2. Determine 200d trading window (ending today)
today = dt.date(2026, 4, 21)
all_td = trading_days_between(today - dt.timedelta(days=400), today)
trading_days = [d.isoformat() for d in all_td[-LOOKBACK_DAYS:]]
print(f"Window: {trading_days[0]}{trading_days[-1]} ({len(trading_days)} trading days)")
# 3. Build date range: need FEATURE_LOOKBACK_TRADING + LOOKBACK_DAYS trading days
extended_td = [d.isoformat() for d in all_td[-(LOOKBACK_DAYS + FEATURE_LOOKBACK_TRADING + 10):]]
first_cal = dt.date.fromisoformat(extended_td[0])
last_cal = dt.date.fromisoformat(trading_days[-1])
# Build full calendar range (including weekends)
needed_dates: list[str] = []
d = first_cal
while d <= last_cal:
needed_dates.append(d.isoformat())
d += dt.timedelta(days=1)
print(f"Daily bar date range: {needed_dates[0]}{needed_dates[-1]} ({len(needed_dates)} calendar days)")
# 4. Load universe
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"\nBuilding daily bars from intraday cache for {len(universe)} tickers...")
daily_bars = build_daily_bars(universe, needed_dates)
print(f"Built daily bars for {len(daily_bars)} tickers")
# 5. Compute enrichment (only over trading_days window)
print("Computing enrichment...")
enrichment = enrich_daily_bars(daily_bars, trading_days)
print(f"Enrichment for {len(enrichment)} tickers")
# 6. Pre-screen candidates + load intraday bars
candidates = orb_pre_screen_candidates(
daily_bars, trading_days, 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,
)
total_pairs = sum(len(v) for v in candidates.values())
print(f"Pre-screened: {total_pairs} ticker-days ({total_pairs/len(trading_days):.0f} avg/day)")
print(f"Loading intraday bars...")
all_intraday = load_intraday_bulk(candidates)
intraday_pairs = sum(len(v) for v in all_intraday.values())
print(f"Loaded: {intraday_pairs} ticker-days")
# 7. Run V23 simulation (full regime filter active — baseline)
print("\nRunning V23 simulation...")
state = ORBSimulationState(equity=params.initial_capital)
day_results, _ = run_orb_simulation_with_state(
all_intraday, trading_days, params, enrichment, state=state,
)
all_trades = [t for dr in day_results for t in dr.trades]
trade_days = sum(1 for dr in day_results if dr.trades)
print(f"Total trades: {len(all_trades)} across {trade_days} trade-days")
# Filter trades with r_multiple_at_exit
trades_with_r = [t for t in all_trades if getattr(t, "r_multiple_at_exit", None) is not None]
print(f"Trades with r_multiple_at_exit: {len(trades_with_r)}/{len(all_trades)}")
if len(trades_with_r) < 40:
print("ABORT: fewer than 40 trades with r_multiple — insufficient sample")
return
# 8. Compute features for each trade
print("\nComputing features for each trade...")
annotated: list[dict] = []
missing = {"hurst": 0, "ou_theta": 0, "obv_slope": 0}
# Pre-sort daily_bars by ticker
sorted_daily: dict[str, list[dict]] = {
t: sorted(bars, key=lambda b: b["date"])
for t, bars in daily_bars.items()
}
for trade in trades_with_r:
ticker = trade.ticker
date = trade.date
r = trade.r_multiple_at_exit
bars_for_ticker = sorted_daily.get(ticker, [])
# Find bars strictly before trade date
prev_bars = [b for b in bars_for_ticker if b["date"][:10] < date]
h = compute_hurst_approx(prev_bars, lookback=60)
ou = compute_ou_theta_approx(prev_bars, lookback=60)
obv = compute_obv_slope_approx(prev_bars, lookback=20)
if h is None:
missing["hurst"] += 1
if ou is None:
missing["ou_theta"] += 1
if obv is None:
missing["obv_slope"] += 1
annotated.append({
"ticker": ticker, "date": date, "r": float(r),
"hurst": h, "ou_theta": ou, "obv_slope": obv,
"win": r > 0,
})
total = len(annotated)
print(f"Annotated: {total} trades")
print(f"Missing values: hurst={missing['hurst']}, ou_theta={missing['ou_theta']}, obv_slope={missing['obv_slope']}")
# 9. Report per-feature
feature_defs = [
("hurst_60", "hurst", "H>0.5 trending → breakout follow-through", False),
("ou_theta_60", "ou_theta", "Low θ = slow reversion = PEAD/breakout friendly", True),
("obv_slope_20", "obv_slope", "Positive = accumulation = smart-money pre-positioning", False),
]
print("\n" + "=" * 90)
print("FEATURE ANALYSIS — V23 200d trade set")
print("=" * 90)
results = {}
for feat_name, feat_key, description, invert in feature_defs:
valid = [(t[feat_key], t["r"]) for t in annotated if t[feat_key] is not None]
if len(valid) < 30:
print(f"\n{feat_name}: SKIP — only {len(valid)} valid trades (need ≥30)")
results[feat_name] = None
continue
vals = [v[0] for v in valid]
rs = [v[1] for v in valid]
wins = [v for v in valid if v[1] > 0]
overall_wr = len(wins) / len(valid)
rho = pearson(vals, rs)
tstat = tercile_stats(vals, rs)
effective_high = "high" if not invert else "low"
effective_low = "low" if not invert else "high"
print(f"\n{''*60}")
print(f"FEATURE: {feat_name}")
print(f" Description: {description}")
print(f" n={len(valid)}, overall WR={overall_wr*100:.1f}%")
print(f" Pearson(feature, r_multiple) = {rho:.4f}" if rho is not None else " Pearson = n/a")
if tstat:
h = tstat["high"]
m = tstat["mid"]
l = tstat["low"]
print(f" Tercile breakdown (low→high feature value):")
print(f" Bottom: n={l['n']}, WR={l['wr']*100:.1f}%, avg_R={l['avg_r']:+.3f}")
print(f" Middle: n={m['n']}, WR={m['wr']*100:.1f}%, avg_R={m['avg_r']:+.3f}")
print(f" Top: n={h['n']}, WR={h['wr']*100:.1f}%, avg_R={h['avg_r']:+.3f}")
# Gate evaluation
best = tstat[effective_high]
worst = tstat[effective_low]
rho_abs = abs(rho) if rho is not None else 0.0
g1 = rho_abs >= 0.07
g2 = best["avg_r"] - worst["avg_r"] >= 0.30
g3 = best["wr"] >= worst["wr"] + 0.05
print(f" Gates ('{effective_high}' vs '{effective_low}' tercile for {feat_name}):")
print(f" G1 |Pearson| ≥ 0.07: {rho_abs:.4f}{'PASS ✓' if g1 else 'FAIL ✗'}")
print(f" G2 avg_R gap ≥ 0.30R: {best['avg_r'] - worst['avg_r']:+.3f}{'PASS ✓' if g2 else 'FAIL ✗'}")
print(f" G3 WR gap ≥ 5pp: {(best['wr'] - worst['wr'])*100:+.1f}pp → {'PASS ✓' if g3 else 'FAIL ✗'}")
overall_pass = g1 and g2 and g3
print(f" VERDICT: {'ALL GATES PASS → PROCEED TO PHASE 2' if overall_pass else f'FAIL ({sum([not g1, not g2, not g3])} gates failed)'}")
results[feat_name] = {"pass": overall_pass, "pearson": rho, "stats": tstat}
# 10. Pairwise feature correlation
h_vals = [t["hurst"] for t in annotated if t["hurst"] is not None and t["ou_theta"] is not None]
ou_vals = [t["ou_theta"] for t in annotated if t["hurst"] is not None and t["ou_theta"] is not None]
obv_h = [t["obv_slope"] for t in annotated if t["hurst"] is not None and t["obv_slope"] is not None]
h_for_obv = [t["hurst"] for t in annotated if t["hurst"] is not None and t["obv_slope"] is not None]
obv_ou = [t["obv_slope"] for t in annotated if t["ou_theta"] is not None and t["obv_slope"] is not None]
ou_for_obv = [t["ou_theta"] for t in annotated if t["ou_theta"] is not None and t["obv_slope"] is not None]
print(f"\n{''*60}")
print("PAIRWISE FEATURE CORRELATIONS:")
rho_h_ou = pearson(h_vals, ou_vals)
rho_h_obv = pearson(h_for_obv, obv_h)
rho_ou_obv = pearson(ou_for_obv, obv_ou)
print(f" Hurst vs OU-θ: {rho_h_ou:.4f}" if rho_h_ou is not None else " Hurst vs OU-θ: n/a")
print(f" Hurst vs OBV: {rho_h_obv:.4f}" if rho_h_obv is not None else " Hurst vs OBV: n/a")
print(f" OU-θ vs OBV: {rho_ou_obv:.4f}" if rho_ou_obv is not None else " OU-θ vs OBV: n/a")
print(" (|ρ| > 0.7 between any pair → use only stronger one in Phase 2)")
# 11. Summary
passing = [name for name, r in results.items() if r is not None and r["pass"]]
print(f"\n{'='*90}")
print(f"SUMMARY")
print(f"{'='*90}")
if passing:
print(f"Features passing all gates: {', '.join(passing)}")
print("VERDICT: PROCEED TO PHASE 2")
print(f" → Add {passing} as scoring weights in ORBStrategyParams")
print(" → Config: orb_gainers_v24_quality_overlay.yaml (parent V23)")
print(" → Weight allocation: 0.15 total split proportionally by |Pearson|")
else:
print("No features passed all gates.")
print("VERDICT: ABORT — Hurst/OU-θ/OBV statistically neutral on V23 200d trade set")
print(" → V23 remains terminal. Entropy-sibling axis exhausted.")
print(" → Next Ralph iteration: FINRA short-volume axis or tape-ignition microstructure")
if __name__ == "__main__":
main()