|
|
|
@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
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()
|