|
|
"""
|
|
|
V26 ORB Tape Ignition Diagnostic — Opening-Range Microstructure Features
|
|
|
|
|
|
Hypothesis (Zarattini/Stucchi SSRN 4729284, 2024): ORB quality is predictable from
|
|
|
the microstructure of the opening-range itself:
|
|
|
(a) Range compression: a tighter ORB relative to ATR → more explosive breakout
|
|
|
(b) Volume slope within ORB: increasing volume → conviction/tape ignition
|
|
|
(c) Volume back-half ratio: second half of ORB bars heavier than first half
|
|
|
(d) Range coil: per-bar range DECREASING during ORB → coiling before explosion
|
|
|
(e) RVOL-normalized volume slope: volume shape independent of magnitude (deconfounds
|
|
|
pre-screen filters already in V24 — min_rvol:1.5 + min_premarket_dollar_vol)
|
|
|
|
|
|
Data: 1-min bars fetched from Oracle (Alpaca endpoint) for each trade (ticker, date)
|
|
|
and cached to data/cache/intraday_1min/{ticker}/{date}.parquet.
|
|
|
The 5-min simulation cache has only one bar per ORB window — 1-min is required.
|
|
|
|
|
|
Method:
|
|
|
1. Run V24 simulation over 200d window → 101 trades with r_multiple
|
|
|
2. Batch-fetch 1-min bars from Oracle for each unique (ticker, date)
|
|
|
3. Extract 5 × 1-min bars in [9:30, 9:35) ET (the ORB window)
|
|
|
4. Compute 5 features (all lookahead-free — ORB closes at 9:35, entry is after 9:35):
|
|
|
orb_range_ratio : (orb_high − orb_low) / atr_14 [compressed = low = good?]
|
|
|
range_coil_orb : slope of [r1..r5] (ri=high_i−low_i) / mean_r [neg = coiling]
|
|
|
vol_slope_orb : slope of [v1..v5] / mean_v [pos = accelerating]
|
|
|
vol_rvol_slope_orb : slope of [v1..v5] / (avg_daily_vol_14d/390) [magnitude-independent]
|
|
|
vol_back_half_ratio : (v4+v5) / (v1+v2) [>1 = tape ignition in second half]
|
|
|
5. Report per-feature: Pearson vs r_multiple, tercile WR/avg_R, coverage
|
|
|
6. Pairwise correlations vs obv_slope_20 and avg_daily_vol_14d (G5a/G5b redundancy)
|
|
|
|
|
|
Gates:
|
|
|
G1: |Pearson| ≥ 0.07 on ≥ 120 trades (relaxed to 60 if coverage < 80%)
|
|
|
G2: |top − bottom tercile avg_R| ≥ 0.30R
|
|
|
G3: |top − bottom tercile WR| ≥ 5pp
|
|
|
G4: feature coverage ≥ 50% of trades
|
|
|
G5a: |ρ(feature, obv_slope_20)| < 0.70 (redundancy vs OBV)
|
|
|
G5b: |ρ(feature, avg_daily_vol_14d)| < 0.70 (redundancy vs magnitude filter)
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import asyncio
|
|
|
import concurrent.futures
|
|
|
import datetime as dt
|
|
|
import json
|
|
|
import os
|
|
|
import sys
|
|
|
from pathlib import Path
|
|
|
|
|
|
import pyarrow as pa
|
|
|
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.config import get_settings
|
|
|
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
|
|
|
from libs.oracle_client.client import OracleClient
|
|
|
|
|
|
# ── Config ──────────────────────────────────────────────────────────────────
|
|
|
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" # 5-min cache (for simulation)
|
|
|
INTRADAY_1MIN_CACHE = "data/cache/intraday_1min" # 1-min cache (for ORB features)
|
|
|
LOOKBACK_DAYS = 200
|
|
|
FEATURE_LOOKBACK_TRADING = 25
|
|
|
|
|
|
MIN_ORB_BARS = 3 # require at least 3 of 5 1-min ORB bars
|
|
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
_MKT_OPEN = dt.time(9, 30)
|
|
|
_MKT_CLOSE = dt.time(16, 0)
|
|
|
_ORB_END = dt.time(9, 35) # 5-min ORB: bars at 9:30, 9:31, 9:32, 9:33, 9:34
|
|
|
|
|
|
|
|
|
# ── Daily Bar Builder (for enrichment — uses 5-min cache) ────────────────────
|
|
|
|
|
|
def _parse_ts(ts_raw: str | 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_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:
|
|
|
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_from_intraday(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]]]:
|
|
|
"""Load 5-min bars for simulation from cache."""
|
|
|
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
|
|
|
|
|
|
|
|
|
# ── 1-Min Oracle Fetcher ──────────────────────────────────────────────────────
|
|
|
|
|
|
async def _fetch_1min_batch(
|
|
|
pairs: list[tuple[str, str]], # [(ticker, date), ...]
|
|
|
oracle_url: str,
|
|
|
concurrency: int = 8,
|
|
|
) -> dict[str, dict[str, list[dict]]]:
|
|
|
"""Fetch 1-min bars for each (ticker, date) from Oracle, cache to disk.
|
|
|
|
|
|
Uses multi-ticker endpoint: GET /api/v1/alpaca/intraday?tickers=A,B&interval=1min&...
|
|
|
Returns {date: {ticker: [bar_dicts]}}.
|
|
|
"""
|
|
|
cache_root = Path(INTRADAY_1MIN_CACHE)
|
|
|
cache_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _cache_path(ticker: str, date: str) -> Path:
|
|
|
return cache_root / ticker / f"{date}.parquet"
|
|
|
|
|
|
def _save_bars(ticker: str, date: str, bars: list[dict]) -> None:
|
|
|
p = _cache_path(ticker, date)
|
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
tbl = pa.table({
|
|
|
"timestamp": [str(b["timestamp"]) for b in bars],
|
|
|
"open": [float(b["open"]) for b in bars],
|
|
|
"high": [float(b["high"]) for b in bars],
|
|
|
"low": [float(b["low"]) for b in bars],
|
|
|
"close": [float(b["close"]) for b in bars],
|
|
|
"volume": [float(b["volume"]) for b in bars],
|
|
|
})
|
|
|
pq.write_table(tbl, str(p))
|
|
|
|
|
|
def _load_bars(ticker: str, date: str) -> list[dict]:
|
|
|
p = _cache_path(ticker, date)
|
|
|
table = pq.read_table(str(p))
|
|
|
rows = table.to_pydict()
|
|
|
return [
|
|
|
{k: rows[k][i] for k in ("timestamp", "open", "high", "low", "close", "volume")}
|
|
|
for i in range(len(rows["timestamp"]))
|
|
|
]
|
|
|
|
|
|
# Split into cache hits and misses; group misses by date for batch calls
|
|
|
hits: list[tuple[str, str]] = []
|
|
|
misses_by_date: dict[str, list[str]] = {}
|
|
|
for ticker, date in pairs:
|
|
|
if _cache_path(ticker, date).exists():
|
|
|
hits.append((ticker, date))
|
|
|
else:
|
|
|
misses_by_date.setdefault(date, []).append(ticker)
|
|
|
|
|
|
n_miss_pairs = sum(len(v) for v in misses_by_date.values())
|
|
|
print(f" 1-min cache: {len(hits)} hits, {n_miss_pairs} Oracle fetches needed ({len(misses_by_date)} dates)")
|
|
|
|
|
|
result: dict[str, dict[str, list[dict]]] = {}
|
|
|
sem = asyncio.Semaphore(concurrency)
|
|
|
fetched_count = 0
|
|
|
|
|
|
async def _fetch_date(date: str, tickers: list[str], client: OracleClient) -> dict[str, list[dict]]:
|
|
|
async with sem:
|
|
|
raw = await client.get(
|
|
|
"/api/v1/alpaca/intraday",
|
|
|
params={
|
|
|
"tickers": ",".join(tickers),
|
|
|
"interval": "1min",
|
|
|
"start_date": date,
|
|
|
"end_date": date,
|
|
|
},
|
|
|
)
|
|
|
bars_by_ticker = raw.get("bars", {})
|
|
|
day_result: dict[str, list[dict]] = {}
|
|
|
for ticker in tickers:
|
|
|
bars = [
|
|
|
{
|
|
|
"timestamp": b.get("timestamp", ""),
|
|
|
"open": float(b.get("open", 0)),
|
|
|
"high": float(b.get("high", 0)),
|
|
|
"low": float(b.get("low", 0)),
|
|
|
"close": float(b.get("close", 0)),
|
|
|
"volume": float(b.get("volume", 0)),
|
|
|
}
|
|
|
for b in bars_by_ticker.get(ticker, [])
|
|
|
]
|
|
|
if bars:
|
|
|
day_result[ticker] = bars
|
|
|
_save_bars(ticker, date, bars)
|
|
|
return day_result
|
|
|
|
|
|
async with OracleClient(base_url=oracle_url) as client:
|
|
|
if misses_by_date:
|
|
|
tasks = {date: _fetch_date(date, tickers, client) for date, tickers in misses_by_date.items()}
|
|
|
dates = list(tasks.keys())
|
|
|
coros = list(tasks.values())
|
|
|
results_list = await asyncio.gather(*coros, return_exceptions=True)
|
|
|
for date, res in zip(dates, results_list):
|
|
|
fetched_count += 1
|
|
|
if isinstance(res, Exception):
|
|
|
print(f" WARN: {date} fetch error: {res}")
|
|
|
continue
|
|
|
if res:
|
|
|
result[date] = result.get(date, {})
|
|
|
result[date].update(res)
|
|
|
if fetched_count % 20 == 0 or fetched_count == len(dates):
|
|
|
print(f" Fetched {fetched_count}/{len(dates)} dates from Oracle")
|
|
|
|
|
|
# Load cache hits
|
|
|
for ticker, date in hits:
|
|
|
try:
|
|
|
bars = _load_bars(ticker, date)
|
|
|
result.setdefault(date, {})[ticker] = bars
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
# ── ORB Bar Extraction ────────────────────────────────────────────────────────
|
|
|
|
|
|
def extract_orb_bars(raw_bars: list[dict]) -> list[dict]:
|
|
|
"""Return 1-min bars with timestamps in [9:30, 9:35) ET, sorted by time."""
|
|
|
orb: list[tuple[dt.datetime, dict]] = []
|
|
|
for bar in raw_bars:
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
if not ts_raw:
|
|
|
continue
|
|
|
try:
|
|
|
ts = _parse_ts(ts_raw)
|
|
|
except Exception:
|
|
|
continue
|
|
|
if _MKT_OPEN <= ts.time() < _ORB_END:
|
|
|
orb.append((ts, bar))
|
|
|
orb.sort(key=lambda x: x[0])
|
|
|
return [b for _, b in orb]
|
|
|
|
|
|
|
|
|
# ── Feature Computations ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _linreg_slope_normalized(vals: list[float]) -> float | None:
|
|
|
"""Linear regression slope over [0..n-1], normalized by mean(vals). Scale-free."""
|
|
|
n = len(vals)
|
|
|
if n < 2:
|
|
|
return None
|
|
|
mean_v = sum(vals) / n
|
|
|
if mean_v <= 0:
|
|
|
return None
|
|
|
x_mean = (n - 1) / 2.0
|
|
|
num = sum((i - x_mean) * (vals[i] - mean_v) for i in range(n))
|
|
|
denom = sum((i - x_mean) ** 2 for i in range(n))
|
|
|
if denom <= 0:
|
|
|
return None
|
|
|
return (num / denom) / mean_v
|
|
|
|
|
|
|
|
|
def compute_orb_range_ratio(orb_bars: list[dict], atr_14: float | None) -> float | None:
|
|
|
"""(orb_high − orb_low) / atr_14. Low = compressed = hypothesis: explosive breakout."""
|
|
|
if len(orb_bars) < MIN_ORB_BARS or atr_14 is None or atr_14 <= 0:
|
|
|
return None
|
|
|
highs = [float(b.get("high", 0) or 0) for b in orb_bars]
|
|
|
lows = [float(b.get("low", 0) or 0) for b in orb_bars]
|
|
|
orb_range = max(highs) - min(lows)
|
|
|
if orb_range <= 0:
|
|
|
return None
|
|
|
return orb_range / atr_14
|
|
|
|
|
|
|
|
|
def compute_range_coil_orb(orb_bars: list[dict]) -> float | None:
|
|
|
"""Normalized slope of per-bar ranges. Negative = bars coiling (tightening)."""
|
|
|
if len(orb_bars) < MIN_ORB_BARS:
|
|
|
return None
|
|
|
ranges = [float((b.get("high") or 0) - (b.get("low") or 0)) for b in orb_bars]
|
|
|
return _linreg_slope_normalized(ranges)
|
|
|
|
|
|
|
|
|
def compute_vol_slope_orb(orb_bars: list[dict]) -> float | None:
|
|
|
"""Normalized slope of 1-min volumes during ORB. Positive = volume increasing."""
|
|
|
if len(orb_bars) < MIN_ORB_BARS:
|
|
|
return None
|
|
|
vols = [float(b.get("volume", 0) or 0) for b in orb_bars]
|
|
|
return _linreg_slope_normalized(vols)
|
|
|
|
|
|
|
|
|
def compute_vol_rvol_slope_orb(
|
|
|
orb_bars: list[dict], avg_daily_vol_14d: float | None
|
|
|
) -> float | None:
|
|
|
"""Volume slope normalized by avg 14d bar volume (deconfounds magnitude filters)."""
|
|
|
if len(orb_bars) < MIN_ORB_BARS or avg_daily_vol_14d is None or avg_daily_vol_14d <= 0:
|
|
|
return None
|
|
|
avg_bar = avg_daily_vol_14d / 390.0
|
|
|
if avg_bar <= 0:
|
|
|
return None
|
|
|
vols = [float(b.get("volume", 0) or 0) / avg_bar for b in orb_bars]
|
|
|
return _linreg_slope_normalized(vols)
|
|
|
|
|
|
|
|
|
def compute_vol_back_half_ratio(orb_bars: list[dict]) -> float | None:
|
|
|
"""(v4+v5) / (v1+v2) — tape ignition: second half volume heavier than first."""
|
|
|
if len(orb_bars) < 4:
|
|
|
return None
|
|
|
vols = [float(b.get("volume", 0) or 0) for b in orb_bars]
|
|
|
first = vols[0] + vols[1]
|
|
|
last = vols[-2] + vols[-1]
|
|
|
if first <= 0:
|
|
|
return None
|
|
|
return last / first
|
|
|
|
|
|
|
|
|
# ── 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(sub):
|
|
|
ys = [p[1] for p in sub]
|
|
|
wr = sum(1 for y in ys if y > 0) / 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("=== V26 ORB Tape Ignition Diagnostic (1-min bars) ===\n")
|
|
|
|
|
|
settings = get_settings()
|
|
|
oracle_url = settings.stock_oracle_url
|
|
|
|
|
|
# 1. Load V24 config
|
|
|
with open(V24_CONFIG) as f:
|
|
|
raw = yaml.safe_load(f)
|
|
|
params = ORBStrategyParams(**raw["orb_strategy"])
|
|
|
print(f"V24 config loaded. weight_obv_slope={params.weight_obv_slope}")
|
|
|
|
|
|
# 2. Determine 200d trading window
|
|
|
today = dt.date(2026, 4, 21)
|
|
|
all_td = trading_days_between(today - dt.timedelta(days=400), today)
|
|
|
trading_days_list = [d.isoformat() for d in all_td[-LOOKBACK_DAYS:]]
|
|
|
print(f"Window: {trading_days_list[0]} → {trading_days_list[-1]} ({len(trading_days_list)} 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_list[-1])
|
|
|
needed_dates: list[str] = []
|
|
|
d = first_cal
|
|
|
while d <= last_cal:
|
|
|
needed_dates.append(d.isoformat())
|
|
|
d += dt.timedelta(days=1)
|
|
|
|
|
|
# 3. 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"Universe: {len(universe)} tickers")
|
|
|
|
|
|
# 4. Build daily bars (5-min cache → daily OHLCV)
|
|
|
print(f"\nBuilding daily bars ({len(needed_dates)} calendar days)...")
|
|
|
daily_bars = build_daily_bars(universe, needed_dates)
|
|
|
print(f"Built daily bars for {len(daily_bars)} tickers")
|
|
|
|
|
|
# 5. Enrichment (provides atr_14, avg_daily_vol_14d, obv_slope_20)
|
|
|
print("Computing enrichment...")
|
|
|
enrichment = enrich_daily_bars(daily_bars, trading_days_list)
|
|
|
print(f"Enrichment for {len(enrichment)} tickers")
|
|
|
|
|
|
# 6. Pre-screen + load 5-min bars for simulation
|
|
|
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,
|
|
|
)
|
|
|
total_pairs = sum(len(v) for v in candidates.values())
|
|
|
print(f"Pre-screened: {total_pairs} ticker-days")
|
|
|
print("Loading 5-min intraday bars for simulation...")
|
|
|
all_intraday = load_intraday_bulk(candidates)
|
|
|
print(f"Loaded: {sum(len(v) for v in all_intraday.values())} ticker-days")
|
|
|
|
|
|
# 7. Run V24 simulation
|
|
|
print("\nRunning 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)}")
|
|
|
|
|
|
if len(trades_with_r) < 20:
|
|
|
print("ABORT: fewer than 20 trades with r_multiple")
|
|
|
return
|
|
|
|
|
|
# 8. Fetch 1-min bars from Oracle for each unique (ticker, date)
|
|
|
trade_pairs = list({(str(t.ticker), str(t.date)[:10]) for t in trades_with_r})
|
|
|
print(f"\nFetching 1-min bars for {len(trade_pairs)} unique (ticker, date) pairs...")
|
|
|
bars_1min = asyncio.run(_fetch_1min_batch(trade_pairs, oracle_url))
|
|
|
n_pairs_loaded = sum(len(v) for v in bars_1min.values())
|
|
|
print(f"1-min data available: {n_pairs_loaded} ticker-days")
|
|
|
|
|
|
# 9. Compute ORB tape ignition features per trade
|
|
|
print("\nComputing ORB tape ignition features...")
|
|
|
sorted_daily: dict[str, list[dict]] = {
|
|
|
t: sorted(bars, key=lambda b: b["date"])
|
|
|
for t, bars in daily_bars.items()
|
|
|
}
|
|
|
|
|
|
annotated: list[dict] = []
|
|
|
missing_counts: dict[str, int] = {
|
|
|
"orb_range_ratio": 0, "range_coil_orb": 0, "vol_slope_orb": 0,
|
|
|
"vol_rvol_slope_orb": 0, "vol_back_half_ratio": 0, "obv_slope": 0,
|
|
|
"no_1min_data": 0,
|
|
|
}
|
|
|
|
|
|
for trade in trades_with_r:
|
|
|
ticker = trade.ticker
|
|
|
date = str(trade.date)[:10]
|
|
|
r = float(trade.r_multiple_at_exit)
|
|
|
|
|
|
# Get 1-min bars for this (ticker, date)
|
|
|
raw_1min = bars_1min.get(date, {}).get(ticker, [])
|
|
|
if not raw_1min:
|
|
|
missing_counts["no_1min_data"] += 1
|
|
|
# Can't compute any ORB features — fill None
|
|
|
orb_bars = []
|
|
|
else:
|
|
|
orb_bars = extract_orb_bars(raw_1min)
|
|
|
|
|
|
# ATR and avg_vol from enrichment
|
|
|
enrich_day = enrichment.get(ticker, {}).get(date, {})
|
|
|
atr_14 = enrich_day.get("atr_14")
|
|
|
avg_daily_vol = enrich_day.get("avg_daily_vol_14d")
|
|
|
|
|
|
f_range_ratio = compute_orb_range_ratio(orb_bars, atr_14)
|
|
|
f_coil = compute_range_coil_orb(orb_bars)
|
|
|
f_vol_slope = compute_vol_slope_orb(orb_bars)
|
|
|
f_vol_rvol_slope = compute_vol_rvol_slope_orb(orb_bars, avg_daily_vol)
|
|
|
f_back_half = compute_vol_back_half_ratio(orb_bars)
|
|
|
|
|
|
# OBV slope for pairwise correlation
|
|
|
bars_t = sorted_daily.get(ticker, [])
|
|
|
prev_bars = [b for b in bars_t if b["date"][:10] < date]
|
|
|
obv = compute_obv_slope_approx(prev_bars, lookback=20)
|
|
|
|
|
|
for fname, fval in [
|
|
|
("orb_range_ratio", f_range_ratio), ("range_coil_orb", f_coil),
|
|
|
("vol_slope_orb", f_vol_slope), ("vol_rvol_slope_orb", f_vol_rvol_slope),
|
|
|
("vol_back_half_ratio", f_back_half),
|
|
|
]:
|
|
|
if fval is None:
|
|
|
missing_counts[fname] += 1
|
|
|
if obv is None:
|
|
|
missing_counts["obv_slope"] += 1
|
|
|
|
|
|
annotated.append({
|
|
|
"ticker": ticker, "date": date, "r": r, "win": r > 0,
|
|
|
"orb_range_ratio": f_range_ratio,
|
|
|
"range_coil_orb": f_coil,
|
|
|
"vol_slope_orb": f_vol_slope,
|
|
|
"vol_rvol_slope_orb": f_vol_rvol_slope,
|
|
|
"vol_back_half_ratio": f_back_half,
|
|
|
"obv_slope": obv,
|
|
|
"avg_daily_vol": avg_daily_vol,
|
|
|
"n_orb_bars": len(orb_bars),
|
|
|
})
|
|
|
|
|
|
total = len(annotated)
|
|
|
print(f"Annotated: {total} trades")
|
|
|
orb_bar_counts = [a["n_orb_bars"] for a in annotated]
|
|
|
avg_orb = sum(orb_bar_counts) / len(orb_bar_counts) if orb_bar_counts else 0
|
|
|
n_full = sum(1 for c in orb_bar_counts if c >= 5)
|
|
|
print(f"ORB bars: avg={avg_orb:.1f}, full(≥5)={n_full}/{total}")
|
|
|
print(f"No 1-min data: {missing_counts['no_1min_data']}/{total}")
|
|
|
for fname in ["orb_range_ratio", "range_coil_orb", "vol_slope_orb", "vol_rvol_slope_orb", "vol_back_half_ratio"]:
|
|
|
print(f" Missing {fname}: {missing_counts[fname]}/{total}")
|
|
|
|
|
|
# 10. Feature analysis
|
|
|
feature_defs = [
|
|
|
("orb_range_ratio", "ORB range / ATR_14 — compressed ORB before breakout", "low"),
|
|
|
("range_coil_orb", "Slope of per-bar ranges — negative = coiling", "low"),
|
|
|
("vol_slope_orb", "Volume slope (shape) during ORB — positive = building conviction", "high"),
|
|
|
("vol_rvol_slope_orb", "Volume slope / avg 14d bar vol — magnitude-independent shape", "high"),
|
|
|
("vol_back_half_ratio", "Vol(last 2 bars) / Vol(first 2 bars) — tape ignition proxy", "high"),
|
|
|
]
|
|
|
|
|
|
print("\n" + "=" * 90)
|
|
|
print("FEATURE ANALYSIS — V24 200d trade set")
|
|
|
print("=" * 90)
|
|
|
|
|
|
results: dict[str, dict | None] = {}
|
|
|
for feat_name, description, best_tercile in feature_defs:
|
|
|
valid = [(a[feat_name], a["r"]) for a in annotated if a[feat_name] is not None]
|
|
|
if len(valid) < 20:
|
|
|
print(f"\n{feat_name}: SKIP — only {len(valid)} valid trades (need ≥20)")
|
|
|
results[feat_name] = None
|
|
|
continue
|
|
|
|
|
|
vals = [v[0] for v in valid]
|
|
|
rs = [v[1] for v in valid]
|
|
|
overall_wr = sum(1 for v in valid if v[1] > 0) / len(valid)
|
|
|
rho = pearson(vals, rs)
|
|
|
tstat = tercile_stats(vals, rs)
|
|
|
coverage_pct = len(valid) / total
|
|
|
worst_tercile = "high" if best_tercile == "low" else "low"
|
|
|
|
|
|
print(f"\n{'─'*60}")
|
|
|
print(f"FEATURE: {feat_name}")
|
|
|
print(f" Description: {description}")
|
|
|
print(f" n={len(valid)}/{total} ({coverage_pct*100:.0f}% coverage), overall WR={overall_wr*100:.1f}%")
|
|
|
rho_abs = abs(rho) if rho is not None else 0.0
|
|
|
print(f" Pearson(feature, r_multiple) = {rho:.4f}" if rho is not None else " Pearson = n/a")
|
|
|
|
|
|
if tstat:
|
|
|
h, m, lo = tstat["high"], tstat["mid"], tstat["low"]
|
|
|
best = tstat[best_tercile]
|
|
|
worst = tstat[worst_tercile]
|
|
|
print(f" Tercile breakdown (low→high feature value):")
|
|
|
print(f" Bottom: n={lo['n']}, WR={lo['wr']*100:.1f}%, avg_R={lo['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}")
|
|
|
|
|
|
min_trades = 120 if coverage_pct >= 0.80 else 60
|
|
|
g1 = rho_abs >= 0.07 and len(valid) >= min_trades
|
|
|
g2 = best["avg_r"] - worst["avg_r"] >= 0.30
|
|
|
g3 = best["wr"] >= worst["wr"] + 0.05
|
|
|
g4 = coverage_pct >= 0.50
|
|
|
n_failed = sum([not g1, not g2, not g3, not g4])
|
|
|
overall_pass = n_failed == 0
|
|
|
|
|
|
print(f" Gates (best='{best_tercile}' tercile):")
|
|
|
print(f" G1 |Pearson|≥0.07 + n≥{min_trades}: {rho_abs:.4f}, n={len(valid)} → {'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 ✗'}")
|
|
|
print(f" G4 coverage ≥ 50%: {coverage_pct*100:.0f}% → {'PASS ✓' if g4 else 'FAIL ✗'}")
|
|
|
print(f" VERDICT: {'ALL GATES PASS → PROCEED TO PHASE 2' if overall_pass else f'FAIL ({n_failed} gate(s) failed)'}")
|
|
|
|
|
|
results[feat_name] = {
|
|
|
"pass": overall_pass, "pearson": rho, "stats": tstat,
|
|
|
"n": len(valid), "coverage": coverage_pct, "best_tercile": best_tercile,
|
|
|
}
|
|
|
|
|
|
# 11. Pairwise correlations (G5a vs OBV, G5b vs avg_daily_vol)
|
|
|
print(f"\n{'─'*60}")
|
|
|
print("PAIRWISE CORRELATIONS (G5a: vs obv_slope_20, G5b: vs avg_daily_vol_14d):")
|
|
|
for feat_name in [fd[0] for fd in feature_defs]:
|
|
|
for ref_key, ref_label, gate_name in [
|
|
|
("obv_slope", "obv_slope_20", "G5a"),
|
|
|
("avg_daily_vol", "avg_daily_vol_14d", "G5b"),
|
|
|
]:
|
|
|
combined = [
|
|
|
(a[feat_name], a[ref_key]) for a in annotated
|
|
|
if a[feat_name] is not None and a[ref_key] is not None
|
|
|
]
|
|
|
if len(combined) >= 10:
|
|
|
rho_x = pearson([c[0] for c in combined], [c[1] for c in combined])
|
|
|
if rho_x is not None:
|
|
|
gate_pass = abs(rho_x) < 0.70
|
|
|
print(f" ρ({feat_name[:24]:24s}, {ref_label}): {rho_x:+.4f} {gate_name}: {'PASS ✓' if gate_pass else 'FAIL (redundant) ✗'}")
|
|
|
|
|
|
# Inter-feature correlations
|
|
|
feat_keys = [fd[0] for fd in feature_defs]
|
|
|
print("\n Inter-feature correlations:")
|
|
|
for i, f1 in enumerate(feat_keys):
|
|
|
for f2 in feat_keys[i+1:]:
|
|
|
combined = [(a[f1], a[f2]) for a in annotated if a[f1] is not None and a[f2] is not None]
|
|
|
if len(combined) >= 10:
|
|
|
rho_x = pearson([c[0] for c in combined], [c[1] for c in combined])
|
|
|
if rho_x is not None:
|
|
|
print(f" ρ({f1[:22]:22s}, {f2[:22]:22s}): {rho_x:+.4f}")
|
|
|
|
|
|
# 12. Summary
|
|
|
passing = [name for name, r in results.items() if r is not None and r["pass"]]
|
|
|
failed = [name for name, r in results.items() if r is not None and not r["pass"]]
|
|
|
skipped = [name for name, r in results.items() if r is None]
|
|
|
|
|
|
print(f"\n{'='*90}")
|
|
|
print("SUMMARY")
|
|
|
print(f"{'='*90}")
|
|
|
print(f"Features passing all gates: {passing if passing else 'NONE'}")
|
|
|
print(f"Features failing gates: {failed if failed else 'NONE'}")
|
|
|
print(f"Features skipped: {skipped if skipped else 'NONE'}")
|
|
|
|
|
|
if passing:
|
|
|
best = max(passing, key=lambda n: abs(results[n]["pearson"] or 0))
|
|
|
pearson_val = results[best]["pearson"]
|
|
|
obv_pearson_ref = 0.2349
|
|
|
weight_magnitude = round(0.05 * min(1.0, abs(pearson_val) / obv_pearson_ref), 2)
|
|
|
weight_magnitude = max(weight_magnitude, 0.02)
|
|
|
best_direction = results[best]["best_tercile"]
|
|
|
weight_sign = +1 if best_direction == "high" else -1
|
|
|
|
|
|
print(f"\nVERDICT: PROCEED TO PHASE 2")
|
|
|
print(f" Best feature: {best}")
|
|
|
print(f" Pearson: {pearson_val:.4f}")
|
|
|
print(f" Direction: '{best_direction}' tercile is best → weight = {weight_sign * weight_magnitude:+.3f}")
|
|
|
print(f" Suggested weight_tape_ignition in V26 config: {weight_sign * weight_magnitude:+.3f}")
|
|
|
print(f"\n → Wire '{best}' as intraday feature in ORB simulator")
|
|
|
print(f" → Add weight_tape_ignition = {weight_sign * weight_magnitude:+.3f} to V26 config (parent V24)")
|
|
|
else:
|
|
|
print(f"\nVERDICT: ABORT — Opening-range tape ignition axis null on V24 200d trade set")
|
|
|
print(" → V24 remains champion. Microstructure axis exhausted (1-min data confirmed).")
|
|
|
print(" → Next Ralph iteration: RSI-14, BB %B, or gravitational pull from libs/features/market_features.py")
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
main()
|