Promote V24 ORB Gainers: add OBV-slope(20d) accumulation quality weight

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 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 9e622c6614
commit 4b8a157a67

@ -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()

@ -1,13 +1,17 @@
_meta: _meta:
id: 28 id: 28
name: "ORB Gainers V23" 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_date: "2026-04-21"
frozen_commit: "e492695e" frozen_commit: "e492695e"
frozen_reason: > frozen_reason: >
Production live baseline (session e492695e). Do not modify; derive new Production live baseline (session e492695e). Superseded by V24 which adds
engines as separate engine_family configs. V23 is the definitive champion OBV-slope(20d) weight=0.05 and improves on all dimensions:
after all 200d/400d/600d validation. Multi-engine Phase 1 begins here. 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: > description: >
V22 → V23 via 2 validated improvements: ATR% quality filter + position cap adjustment. V22 → V23 via 2 validated improvements: ATR% quality filter + position cap adjustment.

@ -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

@ -219,6 +219,28 @@ class StrategyParams(BaseModel):
soft_day_max_trades: int | None = None soft_day_max_trades: int | None = None
"""Maximum number of trades allowed on soft days. None = no extra cap.""" """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 event_sleeve_soft_day_max_trades: int | None = None
"""When set, only enable the soft-day event sleeve if the pre-event basket """When set, only enable the soft-day event sleeve if the pre-event basket
has at most this many selected names.""" 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 """When True, the tail-risk day defense only triggers if no selected pick has
an event-qualified catalyst.""" 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 tail_risk_day_exempt_largecap: bool = False
"""When True, skip the tail-risk day defense whenever the selected basket """When True, skip the tail-risk day defense whenever the selected basket
contains a liquid large-cap candidate.""" contains a liquid large-cap candidate."""
@ -269,6 +299,24 @@ class StrategyParams(BaseModel):
tail_risk_day_scale: float = 1.0 tail_risk_day_scale: float = 1.0
"""Minimum extra day-size scaler applied when the tail-risk defense triggers.""" """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 basket_quality_relative_floor: float | None = None
"""Optional dynamic floor applied after basket selection. """Optional dynamic floor applied after basket selection.
@ -447,6 +495,38 @@ class StrategyParams(BaseModel):
moderate_gap_liquid_max_entropy_20d: float | None = None moderate_gap_liquid_max_entropy_20d: float | None = None
"""Maximum entropy allowed for moderate-gap liquid candidates.""" """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 use_gap_reclaim_sleeve: bool = False
"""Enable a high-gap reclaim sleeve for early flushes that stabilize below the open.""" """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 candidate_intraday_weight_low_entropy: float = 0.0
"""Weighted-mode contribution from lower 20-day entropy.""" """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 candidate_intraday_weight_event_score: float = 0.0
"""Weighted-mode contribution from same-day filing/event score.""" """Weighted-mode contribution from same-day filing/event score."""
@ -888,6 +971,9 @@ class ORBStrategyParams(BaseModel):
weight_atr_ratio: float = 0.0 weight_atr_ratio: float = 0.0
"""Recent ATR(10) / ATR(60) ranking weight.""" """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 weight_gap_zscore: float = 0.0
"""Opening-gap z-score ranking weight relative to prior 20 sessions.""" """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 E.g. 3.0 = exit when trade reaches 3R profit. Locks in gains before
trailing stop gives back profits.""" 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 quality filter ──
orb_range_atr_min: float | None = None orb_range_atr_min: float | None = None
"""Minimum ORB candle range as fraction of ATR(14). None = disabled. """Minimum ORB candle range as fraction of ATR(14). None = disabled.
@ -1661,6 +1754,45 @@ class IntradayTrade(BaseModel):
trade_sleeve: str | None = None trade_sleeve: str | None = None
"""Selection sleeve label for momentum strategies. None for ORB trades.""" """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-specific fields (optional, None for momentum trades)
orb_direction: str | None = None orb_direction: str | None = None
"""ORB trade direction: 'long' or 'short'. None for momentum trades.""" """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).""" """Basket sector-concentration scaler for this day (1.0 = no extra concentration penalty)."""
tail_risk_scaler: float | None = None tail_risk_scaler: float | None = None
"""Extra meta-layer scaler for sparse high-extension tail-risk days.""" """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 is_soft_day: bool = False
"""True when combined_scaler < soft_day_scaler_threshold (soft-regime day).""" """True when combined_scaler < soft_day_scaler_threshold (soft-regime day)."""

@ -124,6 +124,44 @@ def compute_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float
return entropy / max_entropy 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: def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None:
"""Average true range over the last `lookback` completed daily bars.""" """Average true range over the last `lookback` completed daily bars."""
if len(daily_bars) < 2: if len(daily_bars) < 2:
@ -290,6 +328,10 @@ def enrich_daily_bars(
compute_entropy_approx(prev_bars, lookback=20) compute_entropy_approx(prev_bars, lookback=20)
if len(prev_bars) >= 20 else None 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( "atr_ratio_10_60": _compute_ratio(
compute_average_true_range(prev_bars, lookback=10), compute_average_true_range(prev_bars, lookback=10),
compute_average_true_range(prev_bars, lookback=60), compute_average_true_range(prev_bars, lookback=60),

@ -500,6 +500,7 @@ def compute_orb_candidates(
momentum = 0.0 momentum = 0.0
entropy_20d = ticker_enrich.get("entropy_20d") 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") atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60")
range_compression_10_60 = ticker_enrich.get("range_compression_10_60") range_compression_10_60 = ticker_enrich.get("range_compression_10_60")
gap_zscore_20d = ticker_enrich.get("gap_zscore_20d") gap_zscore_20d = ticker_enrich.get("gap_zscore_20d")
@ -623,6 +624,7 @@ def compute_orb_candidates(
"close_location": close_location, "close_location": close_location,
"momentum": momentum, "momentum": momentum,
"entropy_20d": entropy_20d or 0.0, "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, "atr_ratio_10_60": atr_ratio_10_60 or 0.0,
"range_compression_10_60": range_compression_10_60, "range_compression_10_60": range_compression_10_60,
"gap_zscore_20d": gap_zscore_20d or 0.0, "gap_zscore_20d": gap_zscore_20d or 0.0,
@ -703,6 +705,7 @@ def compute_orb_candidates(
for c in raw_candidates for c in raw_candidates
] ]
entropy_vals = [c["entropy_20d"] 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] 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] gap_zscore_vals = [c["gap_zscore_20d"] for c in raw_candidates]
structure_vals = [ structure_vals = [
@ -722,6 +725,7 @@ def compute_orb_candidates(
norm_attention_wiki = _normalize_scores(attention_wiki_vals) norm_attention_wiki = _normalize_scores(attention_wiki_vals)
norm_attention_news = _normalize_scores(attention_news_vals) norm_attention_news = _normalize_scores(attention_news_vals)
norm_entropy = _normalize_scores(entropy_vals) norm_entropy = _normalize_scores(entropy_vals)
norm_obv_slope = _normalize_scores(obv_slope_vals)
norm_atr_ratio = _normalize_scores(atr_ratio_vals) norm_atr_ratio = _normalize_scores(atr_ratio_vals)
norm_gap_zscore = _normalize_scores(gap_zscore_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", "stocks_in_play_dual_regime", "hypergap_failure_v1",
}: }:
score += norm_entropy[i] * params.weight_entropy 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 score += norm_atr_ratio[i] * params.weight_atr_ratio
if engine_family == "compression_breakout": if engine_family == "compression_breakout":
# gap_zscore only added here for compression_breakout; # gap_zscore only added here for compression_breakout;
@ -1358,7 +1363,9 @@ def simulate_orb_trade(
else: else:
sizing_mult = 1.0 sizing_mult = 1.0
risk_dollars = cap * params.risk_per_trade_pct * sizing_mult 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 max_shares_by_capital = (cap * params.max_position_pct) / entry_price_raw
# GFV / cash account constraint: cannot deploy more than available settled cash. # GFV / cash account constraint: cannot deploy more than available settled cash.
@ -1447,6 +1454,20 @@ def simulate_orb_trade(
# --- Phase 2: Manage position --- # --- Phase 2: Manage position ---
current_stop = initial_stop current_stop = initial_stop
trailing_active = False 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) swing_low_window: deque[float] = deque(maxlen=3)
peak_price = entry_price_raw # tracks running high (long) or low (short) for ATR trailing 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"] bar_close = b["close"]
if direction == "long": 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) ── # ── Step 1: Stop check FIRST (broker stop order model) ──
# Check against PREVIOUS bar's stop level. If bar_low touched # Check against PREVIOUS bar's stop level. If bar_low touched
# the stop at any point, the broker fills the stop order. # 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) # Gap-through: bar opened below stop → fill at bar_open (worse)
# Normal: price crossed stop during bar → fill at stop level # Normal: price crossed stop during bar → fill at stop level
exit_price_raw = bar_open if bar_open <= current_stop else current_stop 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 current_stop = candidate_stop
else: # short 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 ── # ── 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_price_raw = bar_open if bar_open >= current_stop else current_stop
exit_time_str = b["timestamp"] exit_time_str = b["timestamp"]
exit_reason = "trailing_stop" if trailing_active else "stop_loss" exit_reason = "trailing_stop" if trailing_active else "stop_loss"

Loading…
Cancel
Save