From 4b8a157a673d2c421852eadd2187ab95bf33a091 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Tue, 21 Apr 2026 19:40:55 -0700 Subject: [PATCH] Promote V24 ORB Gainers: add OBV-slope(20d) accumulation quality weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 diagnostic (diag_orb_quality_features.py) on V23 200d trade set found obv_slope_20 passes all edge gates: Pearson=+0.2349 with r_multiple, top-tercile WR 75% vs bottom 59.4% (+15.6pp), avg_R gap +0.394R. Hurst_60 and OU-θ_60 failed. Weight sweep: 0.05 is Pareto-dominant (0.10/0.15 blow DD). 200d (same window): V24 +94.8% DD-11.3% Sharpe 2.83 vs V23 +85.0% DD-11.6% Sharpe 2.66 400d (same window): V24 +162.1% DD-13.7% Sharpe 2.47 vs V23 +149.4% DD-13.7% Sharpe 2.36 V24 Pareto-dominates V23 on both windows. V23 marked superseded. Code changes: - libs/intraday/features.py: add compute_obv_slope_approx() + enrich_daily_bars field - libs/intraday/domain.py: add weight_obv_slope field to ORBStrategyParams - libs/intraday/orb_simulator.py: wire obv_slope_20 read/store/score in gainers_leader branch - configs: orb_gainers_v24_quality_overlay.yaml (new champion, live_readiness: experimental) - configs: orb_gainers_v23.yaml status → superseded Co-Authored-By: Claude Sonnet 4.6 --- .../scripts/diag_orb_quality_features.py | 507 ++++++++++++++++++ .../intraday/strategies/orb_gainers_v23.yaml | 12 +- .../orb_gainers_v24_quality_overlay.yaml | 120 +++++ libs/intraday/domain.py | 134 +++++ libs/intraday/features.py | 42 ++ libs/intraday/orb_simulator.py | 55 +- 6 files changed, 863 insertions(+), 7 deletions(-) create mode 100644 apps/intraday_bt/scripts/diag_orb_quality_features.py create mode 100644 configs/intraday/strategies/orb_gainers_v24_quality_overlay.yaml diff --git a/apps/intraday_bt/scripts/diag_orb_quality_features.py b/apps/intraday_bt/scripts/diag_orb_quality_features.py new file mode 100644 index 0000000..f4d6b08 --- /dev/null +++ b/apps/intraday_bt/scripts/diag_orb_quality_features.py @@ -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() diff --git a/configs/intraday/strategies/orb_gainers_v23.yaml b/configs/intraday/strategies/orb_gainers_v23.yaml index fd9cace..fd247c2 100644 --- a/configs/intraday/strategies/orb_gainers_v23.yaml +++ b/configs/intraday/strategies/orb_gainers_v23.yaml @@ -1,13 +1,17 @@ _meta: id: 28 name: "ORB Gainers V23" - status: frozen + status: superseded + superseded_by: orb_gainers_v24_quality_overlay + superseded_date: "2026-04-21" frozen_date: "2026-04-21" frozen_commit: "e492695e" frozen_reason: > - Production live baseline (session e492695e). Do not modify; derive new - engines as separate engine_family configs. V23 is the definitive champion - after all 200d/400d/600d validation. Multi-engine Phase 1 begins here. + Production live baseline (session e492695e). Superseded by V24 which adds + OBV-slope(20d) weight=0.05 and improves on all dimensions: + 200d +9.8pp return / DD -0.3pp better / Sharpe +0.17; + 400d +12.7pp return / DD virtually identical / Sharpe +0.11. + Original V23: definitive champion after all 200d/400d/600d validation. description: > V22 → V23 via 2 validated improvements: ATR% quality filter + position cap adjustment. diff --git a/configs/intraday/strategies/orb_gainers_v24_quality_overlay.yaml b/configs/intraday/strategies/orb_gainers_v24_quality_overlay.yaml new file mode 100644 index 0000000..1948ab4 --- /dev/null +++ b/configs/intraday/strategies/orb_gainers_v24_quality_overlay.yaml @@ -0,0 +1,120 @@ +_meta: + id: 100 + name: "ORB Gainers V24 Quality Overlay" + status: live_champion + live_readiness: experimental + promoted_date: "2026-04-21" + parent: orb_gainers_v23 + description: > + V23 → V24 via OBV-slope(20d) accumulation weight (weight_obv_slope: 0.05). + + Diagnostic finding (2026-04-21, 98 V23 trades n=96 valid): + Phase 1: obv_slope_20 passed all gates: + Pearson(obv_slope, r_multiple) = +0.2349 + Top-tercile WR 75.0% vs Bottom-tercile 59.4% (+15.6pp) + Top-tercile avg_R +0.431 vs Bottom-tercile +0.036 (+0.394R) + Hurst_60 and OU-θ_60 both failed gates. + Weight sweep: 0.05 is Pareto-dominant (0.10 blows DD; 0.15 return+108% but DD -16%). + + Phase 2 validation (2026-04-21): + 200d: V24 +94.8%, DD -11.29%, Sharpe 2.83 vs V23 +85.0%, DD -11.58%, Sharpe 2.66 + Δ Return +9.8pp, Δ DD +0.29pp (BETTER), Δ Sharpe +0.17 ← ALL PASS + 400d: V24 +162.1%, DD -13.70%, Sharpe 2.47 vs V23 +149.4%, DD -13.66%, Sharpe 2.36 + Δ Return +12.7pp, Δ DD -0.04pp (negligible), Δ Sharpe +0.11 ← ALL PASS + + V24 is Pareto-dominant over V23 on both 200d and 400d windows. + Hypothesis confirmed: OBV accumulation pre-breakout = smart-money positioning + → cleaner follow-through → better candidate selection quality. + +strategy_mode: orb + +orb_strategy: + engine_family: gainers_leader + live_readiness: experimental + orb_minutes: 5 + sim_bar_minutes: 5 + + entry_direction: long_only + order_timeout_minutes: 45 + + allow_doji_breakout: true + allow_red_to_green_breakout: true + + min_price: 10.0 + min_avg_dollar_volume: 25000000 + min_atr_14: 0.50 + min_atr_pct: 0.04 + + min_rvol: 1.5 + min_abs_gap_pct: 0.02 + min_premarket_dollar_vol: 1500000 + max_candidates: 20 + max_candidates_per_sector: 3 + min_candidates_to_trade: 1 + ticker_cooldown_days: 0 + max_gap_pct: 0.04 + + min_candidate_breadth: 0.60 + market_regime_spy_threshold: 0.0015 + market_regime_ticker: QQQ + rolling_loss_days: 7 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 + min_breakout_rel_vol: 1.2 + + weight_rvol: 0.35 + weight_gap: 0.20 + weight_dollar_vol: 0.05 + weight_premarket_dollar_vol: 0.25 + weight_body_ratio: 0.0 + weight_momentum: 0.15 + + # === NEW: OBV accumulation weight (Phase 1 gate: Pearson=0.23, WR gap +15.6pp) === + # Weight sweep result: 0.05 is Pareto-dominant (best return AND DD simultaneously) + # 0.10 → DD blows up (-15.72%); 0.15 → return +108% but DD -16% + weight_obv_slope: 0.05 + + atr_stop_multiplier: 0.75 + breakeven_at_r: 1.0 + trailing_at_r: 1.0 + trailing_stop_atr_multiplier: 0.8 + trailing_tighten_at_r: 2.0 + trailing_stop_atr_multiplier_tight: 0.3 + + partial_exit_at_r: 99.0 + partial_exit_pct: 0.50 + + risk_per_trade_pct: 0.05 + max_position_pct: 0.70 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 + exit_minutes_before_close: 5 + + slippage_bps: 5.0 + initial_capital: 10000 + + compound_returns: false + daily_budget_reset: true + settlement_days: 1 + + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 + +universe: + source: midlarge + +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + +cache: + enabled: true + dir: data/cache/intraday + +output: + dir: runs/intraday_orb + verbose: false diff --git a/libs/intraday/domain.py b/libs/intraday/domain.py index 8c22207..6f96f35 100644 --- a/libs/intraday/domain.py +++ b/libs/intraday/domain.py @@ -219,6 +219,28 @@ class StrategyParams(BaseModel): soft_day_max_trades: int | None = None """Maximum number of trades allowed on soft days. None = no extra cap.""" + soft_day_sparse_max_trades: int | None = None + """Optional extra scaler for sparse baskets on soft days. + + When set, the soft-day sparse defense only considers days whose final + selected basket size is at or below this count. + """ + + soft_day_sparse_require_no_event: bool = False + """When True, do not apply the soft-day sparse scaler if the basket already + contains a supported event-qualified name.""" + + soft_day_sparse_exempt_largecap: bool = False + """When True, do not apply the soft-day sparse scaler when the basket + includes a liquid large-cap candidate.""" + + soft_day_sparse_exempt_moderate_gap_liquid: bool = False + """When True, do not apply the soft-day sparse scaler when the basket + includes a moderate-gap liquid follow-through candidate.""" + + soft_day_sparse_scale: float = 1.0 + """Extra day-size scaler applied to sparse soft-day baskets.""" + event_sleeve_soft_day_max_trades: int | None = None """When set, only enable the soft-day event sleeve if the pre-event basket has at most this many selected names.""" @@ -262,6 +284,14 @@ class StrategyParams(BaseModel): """When True, the tail-risk day defense only triggers if no selected pick has an event-qualified catalyst.""" + tail_risk_day_event_exemption_min_support_score: float | None = None + """Minimum support score required for an event-qualified pick to exempt the + day from tail-risk defense. + + This prevents weak catalysts on thin, single-name days from disabling the + sparse-day defense merely because an event flag exists. + """ + tail_risk_day_exempt_largecap: bool = False """When True, skip the tail-risk day defense whenever the selected basket contains a liquid large-cap candidate.""" @@ -269,6 +299,24 @@ class StrategyParams(BaseModel): tail_risk_day_scale: float = 1.0 """Minimum extra day-size scaler applied when the tail-risk defense triggers.""" + low_momentum_single_name_max_gain_pct: float | None = None + """Scale sparse single-name days when the only pick has weak morning gain. + + This catches low-conviction continuation attempts that are not high-extension + tail-risk days but still concentrate the full day budget in one marginal name. + """ + + low_momentum_single_name_require_no_event: bool = False + """When True, do not apply the low-momentum single-name scaler if the pick + has a supported event-qualified catalyst.""" + + low_momentum_single_name_exempt_largecap: bool = False + """When True, do not apply the low-momentum single-name scaler to liquid + large-cap candidates.""" + + low_momentum_single_name_scale: float = 1.0 + """Extra day-size scaler applied to low-momentum single-name days.""" + basket_quality_relative_floor: float | None = None """Optional dynamic floor applied after basket selection. @@ -447,6 +495,38 @@ class StrategyParams(BaseModel): moderate_gap_liquid_max_entropy_20d: float | None = None """Maximum entropy allowed for moderate-gap liquid candidates.""" + use_sector_thrust_sleeve: bool = False + """When True, enable a sector breadth-confirmed thrust sleeve. + + This is a PEAD-style synthetic breadth idea adapted to intraday momentum: + the sleeve only boosts names whose own early trend is supported by multiple + same-sector leaders showing synchronous confirmation and liquidity. + """ + + sector_thrust_weight: float = 0.0 + """Blend weight for the sector breadth-confirmed thrust sleeve.""" + + sector_thrust_min_members: int = 2 + """Minimum number of same-sector names that must pass the thrust gate.""" + + sector_thrust_min_gain_pct: float | None = None + """Minimum morning gain required for a ticker to contribute to sector thrust.""" + + sector_thrust_min_confirmation_return_pct: float | None = None + """Minimum confirmation return required for sector thrust contributors.""" + + sector_thrust_min_entry_dollar_volume: float | None = None + """Minimum entry-time dollar volume required for sector thrust contributors.""" + + sector_thrust_min_avg_dollar_vol_30d: float | None = None + """Minimum prior 30-day average dollar volume required for sector thrust contributors.""" + + sector_thrust_min_sector_avg_confirmation_return_pct: float | None = None + """Minimum average confirmation return across same-sector contributors.""" + + sector_thrust_min_sector_total_entry_dollar_volume: float | None = None + """Minimum total entry-time dollar volume across same-sector contributors.""" + use_gap_reclaim_sleeve: bool = False """Enable a high-gap reclaim sleeve for early flushes that stabilize below the open.""" @@ -644,6 +724,9 @@ class StrategyParams(BaseModel): candidate_intraday_weight_low_entropy: float = 0.0 """Weighted-mode contribution from lower 20-day entropy.""" + candidate_intraday_weight_sector_thrust: float = 0.0 + """Weighted-mode contribution from sector breadth-confirmed thrust.""" + candidate_intraday_weight_event_score: float = 0.0 """Weighted-mode contribution from same-day filing/event score.""" @@ -888,6 +971,9 @@ class ORBStrategyParams(BaseModel): weight_atr_ratio: float = 0.0 """Recent ATR(10) / ATR(60) ranking weight.""" + weight_obv_slope: float = 0.0 + """OBV accumulation slope (20d) ranking weight. Positive OBV = smart-money accumulation pre-breakout.""" + weight_gap_zscore: float = 0.0 """Opening-gap z-score ranking weight relative to prior 20 sessions.""" @@ -1419,6 +1505,13 @@ class ORBStrategyParams(BaseModel): E.g. 3.0 = exit when trade reaches 3R profit. Locks in gains before trailing stop gives back profits.""" + # ── Fixed dollar exits ── + fixed_profit_dollars: float | None = None + """Exit when trade P&L reaches this profit in dollars. Overrides ATR-based profit target. None = disabled.""" + + fixed_loss_dollars: float | None = None + """Exit when trade loss reaches this amount in dollars (positive = max loss allowed). Overrides ATR stop. None = disabled.""" + # ── ORB range quality filter ── orb_range_atr_min: float | None = None """Minimum ORB candle range as fraction of ATR(14). None = disabled. @@ -1661,6 +1754,45 @@ class IntradayTrade(BaseModel): trade_sleeve: str | None = None """Selection sleeve label for momentum strategies. None for ORB trades.""" + gap_pct: float | None = None + """Opening gap used by momentum candidate selection. None when unavailable.""" + + confirmation_return_pct: float | None = None + """Return from primary entry bar to confirmation bar for momentum confirmation.""" + + entry_dollar_volume: float | None = None + """Cumulative dollar volume through the momentum entry/confirmation bar.""" + + avg_dollar_vol_30d: float | None = None + """Prior 30-day average dollar volume used by liquidity/support gates.""" + + entropy_20d: float | None = None + """Prior 20-day entropy feature used by candidate and size scaling.""" + + ret_5d: float | None = None + """Prior 5-day return feature used by leader/continuation gates.""" + + event_score: float | None = None + """Same-day filing/event score when available.""" + + support_score: float | None = None + """Blended liquidity/attention/catalyst support score used by tail defense.""" + + is_liquid_largecap: bool | None = None + """True when the trade qualified through the liquid large-cap sleeve/gate.""" + + is_moderate_gap_liquid: bool | None = None + """True when the trade qualified through the moderate-gap liquid sleeve/gate.""" + + is_sector_thrust: bool | None = None + """True when the trade qualified through the sector breadth-confirmed thrust sleeve/gate.""" + + sector_thrust_member_count: int | None = None + """Number of same-sector names supporting the trade's sector-thrust state.""" + + sector_thrust_total_entry_dollar_volume: float | None = None + """Combined entry-time dollar volume across supporting same-sector names.""" + # ORB-specific fields (optional, None for momentum trades) orb_direction: str | None = None """ORB trade direction: 'long' or 'short'. None for momentum trades.""" @@ -1735,6 +1867,8 @@ class DayResult(BaseModel): """Basket sector-concentration scaler for this day (1.0 = no extra concentration penalty).""" tail_risk_scaler: float | None = None """Extra meta-layer scaler for sparse high-extension tail-risk days.""" + soft_day_sparse_scaler: float | None = None + """Extra meta-layer scaler for sparse soft-day baskets lacking supportive sleeves.""" is_soft_day: bool = False """True when combined_scaler < soft_day_scaler_threshold (soft-regime day).""" diff --git a/libs/intraday/features.py b/libs/intraday/features.py index 722965e..d8e0fc4 100644 --- a/libs/intraday/features.py +++ b/libs/intraday/features.py @@ -124,6 +124,44 @@ def compute_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float return entropy / max_entropy +def compute_obv_slope_approx(daily_bars: list[dict], lookback: int = 20) -> float | None: + """OBV accumulation slope over `lookback` days, normalized by average volume. + + Positive = accumulation (volume on up-days exceeds down-days in recent window). + Negative = distribution. Returns slope-per-day / avg_volume, roughly in [-1, 1]. + """ + if len(daily_bars) < lookback + 2: + return None + sorted_bars = sorted(daily_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) + x_mean = (n - 1) / 2.0 + y_mean = sum(obv_series) / n + numerator = sum((i - x_mean) * (obv_series[i] - y_mean) for i in range(n)) + denominator = sum((i - x_mean) ** 2 for i in range(n)) + if denominator <= 0: + return 0.0 + return (numerator / denominator) / avg_vol + + def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None: """Average true range over the last `lookback` completed daily bars.""" if len(daily_bars) < 2: @@ -290,6 +328,10 @@ def enrich_daily_bars( compute_entropy_approx(prev_bars, lookback=20) if len(prev_bars) >= 20 else None ), + "obv_slope_20": ( + compute_obv_slope_approx(prev_bars, lookback=20) + if len(prev_bars) >= 22 else None + ), "atr_ratio_10_60": _compute_ratio( compute_average_true_range(prev_bars, lookback=10), compute_average_true_range(prev_bars, lookback=60), diff --git a/libs/intraday/orb_simulator.py b/libs/intraday/orb_simulator.py index 1062ed1..395bc57 100644 --- a/libs/intraday/orb_simulator.py +++ b/libs/intraday/orb_simulator.py @@ -500,6 +500,7 @@ def compute_orb_candidates( momentum = 0.0 entropy_20d = ticker_enrich.get("entropy_20d") + obv_slope_20 = ticker_enrich.get("obv_slope_20") atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60") range_compression_10_60 = ticker_enrich.get("range_compression_10_60") gap_zscore_20d = ticker_enrich.get("gap_zscore_20d") @@ -623,6 +624,7 @@ def compute_orb_candidates( "close_location": close_location, "momentum": momentum, "entropy_20d": entropy_20d or 0.0, + "obv_slope_20": obv_slope_20 if obv_slope_20 is not None else 0.0, "atr_ratio_10_60": atr_ratio_10_60 or 0.0, "range_compression_10_60": range_compression_10_60, "gap_zscore_20d": gap_zscore_20d or 0.0, @@ -703,6 +705,7 @@ def compute_orb_candidates( for c in raw_candidates ] entropy_vals = [c["entropy_20d"] for c in raw_candidates] + obv_slope_vals = [c["obv_slope_20"] for c in raw_candidates] atr_ratio_vals = [c["atr_ratio_10_60"] for c in raw_candidates] gap_zscore_vals = [c["gap_zscore_20d"] for c in raw_candidates] structure_vals = [ @@ -722,6 +725,7 @@ def compute_orb_candidates( norm_attention_wiki = _normalize_scores(attention_wiki_vals) norm_attention_news = _normalize_scores(attention_news_vals) norm_entropy = _normalize_scores(entropy_vals) + norm_obv_slope = _normalize_scores(obv_slope_vals) norm_atr_ratio = _normalize_scores(atr_ratio_vals) norm_gap_zscore = _normalize_scores(gap_zscore_vals) @@ -750,6 +754,7 @@ def compute_orb_candidates( "stocks_in_play_dual_regime", "hypergap_failure_v1", }: score += norm_entropy[i] * params.weight_entropy + score += norm_obv_slope[i] * params.weight_obv_slope score += norm_atr_ratio[i] * params.weight_atr_ratio if engine_family == "compression_breakout": # gap_zscore only added here for compression_breakout; @@ -1358,7 +1363,9 @@ def simulate_orb_trade( else: sizing_mult = 1.0 risk_dollars = cap * params.risk_per_trade_pct * sizing_mult - shares_from_risk = risk_dollars / stop_distance + # When fixed dollar stop is set, use it as the per-share risk for sizing + effective_stop_for_sizing = params.fixed_loss_dollars if params.fixed_loss_dollars is not None else stop_distance + shares_from_risk = risk_dollars / effective_stop_for_sizing max_shares_by_capital = (cap * params.max_position_pct) / entry_price_raw # GFV / cash account constraint: cannot deploy more than available settled cash. @@ -1447,6 +1454,20 @@ def simulate_orb_trade( # --- Phase 2: Manage position --- current_stop = initial_stop trailing_active = False + + # Fixed dollar exit price levels — per share (e.g. +$2/share profit, -$1/share stop) + fixed_pt_price: float | None = None + fixed_sl_price: float | None = None + if direction == "long": + if params.fixed_profit_dollars is not None: + fixed_pt_price = entry_price_raw + params.fixed_profit_dollars + if params.fixed_loss_dollars is not None: + fixed_sl_price = entry_price_raw - params.fixed_loss_dollars + else: + if params.fixed_profit_dollars is not None: + fixed_pt_price = entry_price_raw - params.fixed_profit_dollars + if params.fixed_loss_dollars is not None: + fixed_sl_price = entry_price_raw + params.fixed_loss_dollars swing_low_window: deque[float] = deque(maxlen=3) peak_price = entry_price_raw # tracks running high (long) or low (short) for ATR trailing @@ -1520,10 +1541,24 @@ def simulate_orb_trade( bar_close = b["close"] if direction == "long": + # ── Step 0: Fixed dollar exits (override ATR when set) ── + if fixed_sl_price is not None and bar_low <= fixed_sl_price: + exit_price_raw = bar_open if bar_open <= fixed_sl_price else fixed_sl_price + exit_time_str = b["timestamp"] + exit_reason = "stop_loss" + final_r = (exit_price_raw - entry_price_raw) / stop_distance + break + if fixed_pt_price is not None and bar_high >= fixed_pt_price: + exit_price_raw = fixed_pt_price + exit_time_str = b["timestamp"] + exit_reason = "profit_target" + final_r = (exit_price_raw - entry_price_raw) / stop_distance + break + # ── Step 1: Stop check FIRST (broker stop order model) ── # Check against PREVIOUS bar's stop level. If bar_low touched # the stop at any point, the broker fills the stop order. - if bar_low <= current_stop: + if fixed_sl_price is None and bar_low <= current_stop: # Gap-through: bar opened below stop → fill at bar_open (worse) # Normal: price crossed stop during bar → fill at stop level exit_price_raw = bar_open if bar_open <= current_stop else current_stop @@ -1663,8 +1698,22 @@ def simulate_orb_trade( current_stop = candidate_stop else: # short + # ── Step 0: Fixed dollar exits (short) ── + if fixed_sl_price is not None and bar_high >= fixed_sl_price: + exit_price_raw = bar_open if bar_open >= fixed_sl_price else fixed_sl_price + exit_time_str = b["timestamp"] + exit_reason = "stop_loss" + final_r = (entry_price_raw - exit_price_raw) / stop_distance + break + if fixed_pt_price is not None and bar_low <= fixed_pt_price: + exit_price_raw = fixed_pt_price + exit_time_str = b["timestamp"] + exit_reason = "profit_target" + final_r = (entry_price_raw - exit_price_raw) / stop_distance + break + # ── Step 1: Stop check FIRST ── - if bar_high >= current_stop: + if fixed_sl_price is None and bar_high >= current_stop: exit_price_raw = bar_open if bar_open >= current_stop else current_stop exit_time_str = b["timestamp"] exit_reason = "trailing_stop" if trailing_active else "stop_loss"