Add V34-V44 ORB diagnostic scripts; wire obv_slope_5 + min_obv_slope_20d infra
Signal axes tested (V34-V44, all failed G2 ≥ 0.30R gate): - V34 obv_slope_5 (5d): G2=0.083R (null) - V35 obv_slope composite (5d+20d): regime artifact (200d +18pp, 400d -12pp) - V36 RSI-14: G2=0.148R, G5a=0.742 (redundant with OBV) - V37 BB %B / BB width: G2=0.186R - V38 dollar_vol_trend / sleep_streak / prior_day / vol_trend: all fail G2 - V39 premarket acceleration + hold ratio: G2=0.013R (null) - V40 prior-day market breadth: G2=0.008R (null) - V41 min_obv_slope_20d=0.0 hard filter: -15pp (OBV as gate too aggressive) - V42 52w-high proximity + range position: G2=0.200R (best near-miss, fails) - V43 30-min ORB window: -14.79% (catastrophic) - V44 trailing multiplier sweep 0.6-1.0: 0.80 confirmed global optimum Infrastructure added (backward-compatible, V24 parity preserved): - features.py: obv_slope_5 enrichment key - domain.py: weight_obv_slope_5=0.0, min_obv_slope_20d=None - orb_simulator.py: 5 wiring sites for obv_slope_5; min_obv_slope_20d gate V24 remains live champion. 20 signal axes exhausted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
8bd4dba89c
commit
16f49411cb
@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
V39 Diagnostic: Pre-Market Volume Acceleration Signal
|
||||||
|
|
||||||
|
Hypothesis: The QUALITY of pre-market activity matters beyond its magnitude.
|
||||||
|
A stock where pre-market volume is BUILDING into the open (acceleration) suggests
|
||||||
|
fresh sustained demand vs. a single early spike that fades.
|
||||||
|
|
||||||
|
The existing weight_premarket_dollar_vol captures the QUANTITY of pre-market activity.
|
||||||
|
This tests the SHAPE — specifically whether volume is accelerating toward the open.
|
||||||
|
|
||||||
|
Features (computed from pre-market intraday bars 4:00-9:29 ET):
|
||||||
|
pm_accel_ratio : last_30min_vol / first_half_vol — >1.0 means volume building
|
||||||
|
pm_vol_per_bar : premarket_dollar_vol / pm_bar_count — density measure
|
||||||
|
pm_hold_ratio : last_premarket_close / session_premarket_high — is price holding near high?
|
||||||
|
|
||||||
|
Context: The V24 400d run has trade dates with full intraday data in cache.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_ET = ZoneInfo("America/New_York")
|
||||||
|
_PRE_OPEN = dt.time(4, 0)
|
||||||
|
_MKT_OPEN = dt.time(9, 30)
|
||||||
|
|
||||||
|
INTRADAY_CACHE_DIR = "data/cache/intraday"
|
||||||
|
V24_400D_RUN = "runs/intraday_orb/intraday_20260422_011012_06f59ede.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts_raw: object) -> dt.datetime:
|
||||||
|
s = str(ts_raw)
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
return dt.datetime.fromisoformat(s).astimezone(_ET)
|
||||||
|
|
||||||
|
|
||||||
|
def load_premarket_bars(ticker: str, date: str) -> list[dict]:
|
||||||
|
"""Load all pre-market bars (4:00-9:29 ET) for a ticker on a date."""
|
||||||
|
path = Path(INTRADAY_CACHE_DIR) / ticker / f"{date}.parquet"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
table = pq.read_table(str(path))
|
||||||
|
rows = table.to_pydict()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
bars = []
|
||||||
|
for i, ts_raw in enumerate(rows.get("timestamp", [])):
|
||||||
|
try:
|
||||||
|
ts = _parse_ts(ts_raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if _PRE_OPEN <= ts.time() < _MKT_OPEN:
|
||||||
|
o = float(rows["open"][i] or 0)
|
||||||
|
h = float(rows["high"][i] or 0)
|
||||||
|
lo = float(rows["low"][i] or 0)
|
||||||
|
c = float(rows["close"][i] or 0)
|
||||||
|
v = float(rows["volume"][i] or 0)
|
||||||
|
if v > 0 and c > 0:
|
||||||
|
bars.append({
|
||||||
|
"time": ts.time(), "open": o, "high": h,
|
||||||
|
"low": lo, "close": c, "volume": v, "dv": c * v,
|
||||||
|
})
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pm_features(pm_bars: list[dict]) -> dict | None:
|
||||||
|
if len(pm_bars) < 4:
|
||||||
|
return None
|
||||||
|
total_dv = sum(b["dv"] for b in pm_bars)
|
||||||
|
if total_dv <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
n = len(pm_bars)
|
||||||
|
# Acceleration: last 30 min (last ~6 bars) vs first half
|
||||||
|
last_6 = pm_bars[-6:]
|
||||||
|
first_half = pm_bars[:n // 2]
|
||||||
|
last_vol = sum(b["dv"] for b in last_6)
|
||||||
|
first_vol = sum(b["dv"] for b in first_half)
|
||||||
|
if first_vol <= 0:
|
||||||
|
accel_ratio = None
|
||||||
|
else:
|
||||||
|
accel_ratio = last_vol / first_vol
|
||||||
|
|
||||||
|
# Price hold: last close vs session high
|
||||||
|
pm_high = max(b["high"] for b in pm_bars)
|
||||||
|
last_close = pm_bars[-1]["close"]
|
||||||
|
if pm_high <= 0:
|
||||||
|
hold_ratio = None
|
||||||
|
else:
|
||||||
|
hold_ratio = last_close / pm_high
|
||||||
|
|
||||||
|
return {
|
||||||
|
"accel_ratio": accel_ratio,
|
||||||
|
"hold_ratio": hold_ratio,
|
||||||
|
"bar_count": n,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pearson(xs: list[float], ys: list[float]) -> float | None:
|
||||||
|
n = len(xs)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
xm, ym = sum(xs) / n, 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], rs: list[float]) -> dict:
|
||||||
|
if len(vals) < 9:
|
||||||
|
return {}
|
||||||
|
pairs = sorted(zip(vals, rs), key=lambda p: p[0])
|
||||||
|
n = len(pairs)
|
||||||
|
t = n // 3
|
||||||
|
def stats(sub):
|
||||||
|
ys = [p[1] for p in sub]
|
||||||
|
return {"n": len(ys), "wr": sum(1 for y in ys if y > 0) / len(ys), "avg_r": sum(ys) / len(ys)}
|
||||||
|
return {"low": stats(pairs[:t]), "mid": stats(pairs[t:2*t]), "high": stats(pairs[2*t:])}
|
||||||
|
|
||||||
|
|
||||||
|
def report_feature(label: str, vals: list[float], rs: list[float]) -> None:
|
||||||
|
n = len(vals)
|
||||||
|
p = pearson(vals, rs)
|
||||||
|
ts = tercile_stats(vals, rs)
|
||||||
|
if not ts or p is None:
|
||||||
|
print(f" {label}: n={n}, insufficient data")
|
||||||
|
return
|
||||||
|
low, mid, high = ts["low"], ts["mid"], ts["high"]
|
||||||
|
avg_r_gap = abs(high["avg_r"] - low["avg_r"])
|
||||||
|
wr_gap = abs(high["wr"] - low["wr"])
|
||||||
|
g1 = abs(p) >= 0.07 and n >= 120
|
||||||
|
g2 = avg_r_gap >= 0.30
|
||||||
|
g3 = wr_gap >= 0.05
|
||||||
|
print(f"\n [{label}] n={n} Pearson={p:+.3f}")
|
||||||
|
print(f" G1: {'PASS' if g1 else 'FAIL'} (|{abs(p):.3f}| {'≥' if abs(p)>=0.07 else '<'} 0.07, n={n})")
|
||||||
|
print(f" G2: {'PASS' if g2 else 'FAIL'} (avg_R gap = {avg_r_gap:.3f}R [threshold 0.30R])")
|
||||||
|
print(f" G3: {'PASS' if g3 else 'FAIL'} (WR gap = {wr_gap*100:.1f}pp [threshold 5pp])")
|
||||||
|
print(f" Bottom tercile: WR {low['wr']*100:.1f}% avg_R {low['avg_r']:+.3f} n={low['n']}")
|
||||||
|
print(f" Middle tercile: WR {mid['wr']*100:.1f}% avg_R {mid['avg_r']:+.3f} n={mid['n']}")
|
||||||
|
print(f" Top tercile: WR {high['wr']*100:.1f}% avg_R {high['avg_r']:+.3f} n={high['n']}")
|
||||||
|
print(f" → {'ALL GATES PASS ✓' if (g1 and g2 and g3) else 'FAIL'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("=== V39 Pre-Market Volume Acceleration Diagnostic ===\n")
|
||||||
|
|
||||||
|
with open(V24_400D_RUN) as f:
|
||||||
|
run_data = json.load(f)
|
||||||
|
trades = run_data.get("trades", [])
|
||||||
|
m = run_data.get("metrics", {})
|
||||||
|
print(f"Loaded V24 400d: {m.get('total_trades')} trades, "
|
||||||
|
f"{m.get('start_date')} → {m.get('end_date')}")
|
||||||
|
print(f"Return: {m.get('total_return_pct', 0)*100:.2f}% Sharpe: {m.get('sharpe_ratio', 0):.3f}\n")
|
||||||
|
|
||||||
|
trade_records = [
|
||||||
|
{"ticker": t["ticker"], "date": t["date"][:10],
|
||||||
|
"r_multiple": float(t["r_multiple_at_exit"])}
|
||||||
|
for t in trades
|
||||||
|
if t.get("r_multiple_at_exit") is not None
|
||||||
|
]
|
||||||
|
print(f"Trades with r_multiple: {len(trade_records)}")
|
||||||
|
|
||||||
|
accel_vals, hold_vals, r_mults = [], [], []
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
for rec in trade_records:
|
||||||
|
pm_bars = load_premarket_bars(rec["ticker"], rec["date"])
|
||||||
|
feats = compute_pm_features(pm_bars)
|
||||||
|
if feats is None or feats["accel_ratio"] is None or feats["hold_ratio"] is None:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
accel_vals.append(feats["accel_ratio"])
|
||||||
|
hold_vals.append(feats["hold_ratio"])
|
||||||
|
r_mults.append(rec["r_multiple"])
|
||||||
|
|
||||||
|
n_valid = len(r_mults)
|
||||||
|
print(f"Valid trades (pm data): {n_valid} / {len(trade_records)} (missing: {missing})")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("GATE RESULTS (G1: |P|≥0.07 & n≥120; G2: avg_R≥0.30R; G3: WR≥5pp)")
|
||||||
|
|
||||||
|
report_feature("pm_accel_ratio (last_30min_dv / first_half_dv)", accel_vals, r_mults)
|
||||||
|
report_feature("pm_hold_ratio (last_close / pm_high)", hold_vals, r_mults)
|
||||||
|
|
||||||
|
print("\n=== Summary ===")
|
||||||
|
print("OBV-slope 20d: Pearson=+0.235, G2=0.394R — the only passing axis.")
|
||||||
|
print("This tests INTRADAY pre-market shape, not captured by existing premarket_dollar_vol.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
V38 Diagnostic: Remaining unexplored daily feature axes
|
||||||
|
|
||||||
|
Tests 4 features not yet explored against V24 trade set (400d, n=304):
|
||||||
|
|
||||||
|
1. dollar_vol_trend : avg_dollar_vol_10d / avg_dollar_vol_30d — is dollar volume
|
||||||
|
ACCELERATING? If yes, recent activity surge above baseline.
|
||||||
|
2. sleep_streak : consecutive prior days with |daily_return| < 1.5% —
|
||||||
|
"coiled spring" hypothesis: long quiet period before explosive gap.
|
||||||
|
3. prior_day_return : yesterday's close vs. day-before close (1-day momentum/extension check).
|
||||||
|
Hypothesis: stocks that were flat/down yesterday have better ORB
|
||||||
|
follow-through (less extension) than stocks already up yesterday.
|
||||||
|
4. vol_trend_ratio : avg_volume_5d / avg_volume_20d — is volume accelerating (short-term)?
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_ET = ZoneInfo("America/New_York")
|
||||||
|
_MKT_OPEN = dt.time(9, 30)
|
||||||
|
_MKT_CLOSE = dt.time(16, 0)
|
||||||
|
|
||||||
|
INTRADAY_CACHE_DIR = "data/cache/intraday"
|
||||||
|
V24_400D_RUN = "runs/intraday_orb/intraday_20260422_011012_06f59ede.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts_raw: 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(path: Path, date: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
table = pq.read_table(str(path))
|
||||||
|
rows = table.to_pydict()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
opens, 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))
|
||||||
|
closes.append(float(rows["close"][i] or 0))
|
||||||
|
vols.append(float(rows["volume"][i] or 0))
|
||||||
|
if not closes or closes[-1] <= 0:
|
||||||
|
return None
|
||||||
|
close = closes[-1]
|
||||||
|
vol = sum(vols)
|
||||||
|
return {"date": date, "open": opens[0] if opens else 0, "close": close,
|
||||||
|
"volume": vol, "dollar_vol": close * vol}
|
||||||
|
|
||||||
|
|
||||||
|
def load_ticker_bars(ticker: str, all_dates: list[str]) -> list[dict]:
|
||||||
|
root = Path(INTRADAY_CACHE_DIR) / ticker
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
bars = []
|
||||||
|
for date in all_dates:
|
||||||
|
p = root / f"{date}.parquet"
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
bar = _build_daily_bar(p, date)
|
||||||
|
if bar and bar["close"] > 0:
|
||||||
|
bars.append(bar)
|
||||||
|
return sorted(bars, key=lambda b: b["date"])
|
||||||
|
|
||||||
|
|
||||||
|
def _avg(vals: list[float]) -> float:
|
||||||
|
return sum(vals) / len(vals) if vals else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_dollar_vol_trend(prev_bars: list[dict]) -> float | None:
|
||||||
|
if len(prev_bars) < 30:
|
||||||
|
return None
|
||||||
|
dv_5 = _avg([b["dollar_vol"] for b in prev_bars[-10:]])
|
||||||
|
dv_30 = _avg([b["dollar_vol"] for b in prev_bars[-30:]])
|
||||||
|
if dv_30 <= 0:
|
||||||
|
return None
|
||||||
|
return dv_5 / dv_30
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sleep_streak(prev_bars: list[dict], threshold: float = 0.015) -> float | None:
|
||||||
|
"""Count consecutive prior days with |return| < threshold."""
|
||||||
|
if len(prev_bars) < 2:
|
||||||
|
return None
|
||||||
|
streak = 0
|
||||||
|
for i in range(len(prev_bars) - 1, 0, -1):
|
||||||
|
c = prev_bars[i]["close"]
|
||||||
|
pc = prev_bars[i - 1]["close"]
|
||||||
|
if pc <= 0:
|
||||||
|
break
|
||||||
|
ret = abs((c - pc) / pc)
|
||||||
|
if ret < threshold:
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return float(streak)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_prior_day_return(prev_bars: list[dict]) -> float | None:
|
||||||
|
if len(prev_bars) < 2:
|
||||||
|
return None
|
||||||
|
c = prev_bars[-1]["close"]
|
||||||
|
pc = prev_bars[-2]["close"]
|
||||||
|
if pc <= 0:
|
||||||
|
return None
|
||||||
|
return (c - pc) / pc
|
||||||
|
|
||||||
|
|
||||||
|
def compute_vol_trend_ratio(prev_bars: list[dict]) -> float | None:
|
||||||
|
if len(prev_bars) < 20:
|
||||||
|
return None
|
||||||
|
v5 = _avg([b["volume"] for b in prev_bars[-5:]])
|
||||||
|
v20 = _avg([b["volume"] for b in prev_bars[-20:]])
|
||||||
|
if v20 <= 0:
|
||||||
|
return None
|
||||||
|
return v5 / v20
|
||||||
|
|
||||||
|
|
||||||
|
def pearson(xs: list[float], ys: list[float]) -> float | None:
|
||||||
|
n = len(xs)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
xm, ym = sum(xs) / n, 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], rs: list[float]) -> dict:
|
||||||
|
if len(vals) < 9:
|
||||||
|
return {}
|
||||||
|
pairs = sorted(zip(vals, rs), key=lambda p: p[0])
|
||||||
|
n = len(pairs)
|
||||||
|
t = n // 3
|
||||||
|
def stats(sub):
|
||||||
|
ys = [p[1] for p in sub]
|
||||||
|
return {"n": len(ys), "wr": sum(1 for y in ys if y > 0) / len(ys), "avg_r": sum(ys) / len(ys)}
|
||||||
|
return {"low": stats(pairs[:t]), "mid": stats(pairs[t:2*t]), "high": stats(pairs[2*t:])}
|
||||||
|
|
||||||
|
|
||||||
|
def report_feature(label: str, vals: list[float], rs: list[float]) -> None:
|
||||||
|
n = len(vals)
|
||||||
|
p = pearson(vals, rs)
|
||||||
|
ts = tercile_stats(vals, rs)
|
||||||
|
if not ts or p is None:
|
||||||
|
print(f" {label}: n={n}, insufficient data")
|
||||||
|
return
|
||||||
|
low, mid, high = ts["low"], ts["mid"], ts["high"]
|
||||||
|
avg_r_gap = abs(high["avg_r"] - low["avg_r"])
|
||||||
|
wr_gap = abs(high["wr"] - low["wr"])
|
||||||
|
g1 = abs(p) >= 0.07 and n >= 120
|
||||||
|
g2 = avg_r_gap >= 0.30
|
||||||
|
g3 = wr_gap >= 0.05
|
||||||
|
print(f"\n [{label}] n={n} Pearson={p:+.3f}")
|
||||||
|
print(f" G1: {'PASS' if g1 else 'FAIL'} (|{abs(p):.3f}| {'≥' if abs(p)>=0.07 else '<'} 0.07, n={n})")
|
||||||
|
print(f" G2: {'PASS' if g2 else 'FAIL'} (avg_R gap = {avg_r_gap:.3f}R [threshold 0.30R])")
|
||||||
|
print(f" G3: {'PASS' if g3 else 'FAIL'} (WR gap = {wr_gap*100:.1f}pp [threshold 5pp])")
|
||||||
|
print(f" Bottom tercile: WR {low['wr']*100:.1f}% avg_R {low['avg_r']:+.3f} n={low['n']}")
|
||||||
|
print(f" Middle tercile: WR {mid['wr']*100:.1f}% avg_R {mid['avg_r']:+.3f} n={mid['n']}")
|
||||||
|
print(f" Top tercile: WR {high['wr']*100:.1f}% avg_R {high['avg_r']:+.3f} n={high['n']}")
|
||||||
|
print(f" → {'ALL GATES PASS ✓' if (g1 and g2 and g3) else 'FAIL'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("=== V38 Remaining Daily Features Diagnostic ===\n")
|
||||||
|
|
||||||
|
with open(V24_400D_RUN) as f:
|
||||||
|
run_data = json.load(f)
|
||||||
|
trades = run_data.get("trades", [])
|
||||||
|
m = run_data.get("metrics", {})
|
||||||
|
print(f"Loaded V24 400d run: {m.get('total_trades')} trades, "
|
||||||
|
f"{m.get('start_date')} → {m.get('end_date')}")
|
||||||
|
print(f"Return: {m.get('total_return_pct', 0)*100:.2f}% DD: {m.get('max_drawdown_pct', 0)*100:.2f}% Sharpe: {m.get('sharpe_ratio', 0):.3f}\n")
|
||||||
|
|
||||||
|
trade_records = [
|
||||||
|
{"ticker": t["ticker"], "date": t["date"][:10],
|
||||||
|
"r_multiple": float(t["r_multiple_at_exit"])}
|
||||||
|
for t in trades
|
||||||
|
if t.get("r_multiple_at_exit") is not None
|
||||||
|
]
|
||||||
|
print(f"Trades with r_multiple: {len(trade_records)}")
|
||||||
|
|
||||||
|
tickers_needed = sorted(set(r["ticker"] for r in trade_records))
|
||||||
|
min_date = min(r["date"] for r in trade_records)
|
||||||
|
max_date = max(r["date"] for r in trade_records)
|
||||||
|
start_cal = (dt.date.fromisoformat(min_date) - dt.timedelta(days=120)).isoformat()
|
||||||
|
all_dates = []
|
||||||
|
d = dt.date.fromisoformat(start_cal)
|
||||||
|
end_d = dt.date.fromisoformat(max_date)
|
||||||
|
while d <= end_d:
|
||||||
|
all_dates.append(d.isoformat())
|
||||||
|
d += dt.timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"Loading bars for {len(tickers_needed)} tickers...")
|
||||||
|
ticker_bars: dict[str, list[dict]] = {}
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
||||||
|
def _load(ticker: str) -> tuple[str, list[dict]]:
|
||||||
|
return ticker, load_ticker_bars(ticker, all_dates)
|
||||||
|
for ticker, bars in ex.map(_load, tickers_needed):
|
||||||
|
if bars:
|
||||||
|
ticker_bars[ticker] = bars
|
||||||
|
print(f"Loaded: {len(ticker_bars)} / {len(tickers_needed)} tickers\n")
|
||||||
|
|
||||||
|
dv_trend_all, sleep_all, prior_ret_all, vol_trend_all, r_mults_all = [], [], [], [], []
|
||||||
|
|
||||||
|
for rec in trade_records:
|
||||||
|
ticker = rec["ticker"]
|
||||||
|
date = rec["date"]
|
||||||
|
r = rec["r_multiple"]
|
||||||
|
bars = ticker_bars.get(ticker, [])
|
||||||
|
prev = [b for b in bars if b["date"] < date]
|
||||||
|
|
||||||
|
dv = compute_dollar_vol_trend(prev)
|
||||||
|
sl = compute_sleep_streak(prev)
|
||||||
|
pr = compute_prior_day_return(prev)
|
||||||
|
vt = compute_vol_trend_ratio(prev)
|
||||||
|
|
||||||
|
if dv is None or sl is None or pr is None or vt is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dv_trend_all.append(dv)
|
||||||
|
sleep_all.append(sl)
|
||||||
|
prior_ret_all.append(pr)
|
||||||
|
vol_trend_all.append(vt)
|
||||||
|
r_mults_all.append(r)
|
||||||
|
|
||||||
|
print(f"Valid trades (all 4 features): {len(r_mults_all)} / {len(trade_records)}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("GATE RESULTS (G1: |P|≥0.07 & n≥120; G2: avg_R≥0.30R; G3: WR≥5pp)")
|
||||||
|
|
||||||
|
report_feature("dollar_vol_trend (dvol10d/dvol30d)", dv_trend_all, r_mults_all)
|
||||||
|
report_feature("sleep_streak (consec. low-vol days)", sleep_all, r_mults_all)
|
||||||
|
report_feature("prior_day_return", prior_ret_all, r_mults_all)
|
||||||
|
report_feature("vol_trend_ratio (vol5d/vol20d)", vol_trend_all, r_mults_all)
|
||||||
|
|
||||||
|
print("\n=== Benchmark ===")
|
||||||
|
print("OBV-slope 20d: Pearson=+0.235, G2=0.394R — only passing signal.")
|
||||||
|
print("Any G2 ≥ 0.30R here → advance to wiring. Otherwise → V24 is terminal for daily OHLCV axis.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
_meta:
|
||||||
|
id: 109
|
||||||
|
name: "ORB Gainers V41 OBV Filter"
|
||||||
|
status: candidate
|
||||||
|
live_readiness: experimental
|
||||||
|
parent: orb_gainers_v24_quality_overlay
|
||||||
|
description: >
|
||||||
|
V24 → V41: converts the OBV-slope weight into a HARD FILTER.
|
||||||
|
|
||||||
|
V24 uses OBV-slope as a continuous scoring weight (weight_obv_slope: 0.05).
|
||||||
|
V41 keeps the weight AND adds min_obv_slope_20d: 0.0 to filter out candidates
|
||||||
|
in active distribution (negative OBV slope = institutions selling net).
|
||||||
|
|
||||||
|
Hypothesis: stocks with OBV_slope_20 < 0 represent institutional distribution.
|
||||||
|
Even if they rank well on rvol/gap/premarket, entering a distributing stock on
|
||||||
|
a gap-up is a lower-quality setup. Filtering them out improves trade quality
|
||||||
|
at the cost of some volume reduction.
|
||||||
|
|
||||||
|
The filter is additive to (not replacing) the weight.
|
||||||
|
All other V24 params unchanged.
|
||||||
|
|
||||||
|
Gates (200d): Return ≥ +98.8% (V24 +94.8% + 4pp), DD ≥ -11.79%, Sharpe ≥ 2.93
|
||||||
|
Gates (400d): Return ≥ +166.1% (V24 +162.1% + 4pp), DD ≥ -14.20%, Sharpe ≥ 2.52
|
||||||
|
|
||||||
|
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
|
||||||
|
weight_obv_slope: 0.05
|
||||||
|
|
||||||
|
# V41 change: hard filter — only trade stocks in accumulation (positive OBV slope)
|
||||||
|
min_obv_slope_20d: 0.0
|
||||||
|
|
||||||
|
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
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
_meta:
|
||||||
|
id: 110
|
||||||
|
name: "ORB Gainers V43 30-Minute ORB"
|
||||||
|
status: candidate
|
||||||
|
live_readiness: experimental
|
||||||
|
parent: orb_gainers_v24_quality_overlay
|
||||||
|
description: >
|
||||||
|
V24 → V43: changes the ORB window from 5-min to 30-min.
|
||||||
|
|
||||||
|
V24 uses the first 5-min candle (9:30-9:35 ET) as the opening range.
|
||||||
|
V43 uses the first 30-min window (9:30-10:00 ET) as the opening range.
|
||||||
|
|
||||||
|
Hypothesis: a 30-min ORB gives more time for:
|
||||||
|
(a) False breakouts to resolve — early spikes and fades complete within the window
|
||||||
|
(b) Institutional orderflow to participate — large orders execute over 30 min
|
||||||
|
(c) The true range to establish — less noise in the high/low
|
||||||
|
|
||||||
|
Risk: entering later (at 10:00+) rather than 9:35 means:
|
||||||
|
(a) Less time in trade, exit pressure before 4 PM
|
||||||
|
(b) May miss some early morning momentum
|
||||||
|
|
||||||
|
All other V24 params unchanged.
|
||||||
|
|
||||||
|
Gates (200d): Return ≥ +98.8% (V24 +94.8% + 4pp), DD ≥ -11.79%, Sharpe ≥ 2.93
|
||||||
|
Gates (400d): Return ≥ +166.1% (V24 +162.1% + 4pp), DD ≥ -14.20%, Sharpe ≥ 2.52
|
||||||
|
|
||||||
|
strategy_mode: orb
|
||||||
|
|
||||||
|
orb_strategy:
|
||||||
|
engine_family: gainers_leader
|
||||||
|
live_readiness: experimental
|
||||||
|
orb_minutes: 30
|
||||||
|
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
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue