"""Phase A — Synthetic Entry Dataset for ORB Continuation-Value ML. For each (ticker, date) in the broad universe × target date range, defines a hypothetical long entry at the 9:35 ET bar open. Computes entry features (ATR_14, gap_pct, rvol, etc.) from lookahead-free intraday/daily aggregates. Applies a minimal hygiene filter and marks V49.91 actual entries. Outputs synthetic_entries.parquet: one row per valid (ticker, date) pair. Columns are structured to match the field layout expected by orb_per_bar_state.py (Phase B). Usage: python scripts/orb_synthetic_entries.py \ --universe configs/symbols_broad_snapshot_3408.yaml \ --v49-run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \ --out tmp/orb_phase_a_synthetic_entries \ --workers 8 """ from __future__ import annotations import argparse import json import math import os import sys from datetime import time as dtime from functools import partial from multiprocessing import Pool from pathlib import Path import numpy as np import pandas as pd import yaml sys.path.insert(0, str(Path(__file__).parent.parent)) from libs.intraday.features import ( compute_atr_from_dicts, compute_avg_dollar_volume, compute_avg_daily_volume, compute_entropy_approx, compute_obv_slope_approx, compute_gap_zscore, ) # ── constants ─────────────────────────────────────────────────────────────── ATR_STOP_MULT = 0.75 MIN_ATR = 0.10 # skip if ATR too tiny (degenerate ticker) MIN_DOLLAR_VOL = 100_000 # $/bar hygiene MIN_BARS_TO_EOD = 70 # must have at least this many 5-min bars from entry LOOKBACK_DAYS = 80 # extra intraday files to load before target range for rolling TARGET_START = "2024-09-12" TARGET_END = "2026-05-05" ET = "America/New_York" MARKET_OPEN_ET = dtime(9, 30) ENTRY_TIME_ET = dtime(9, 35) MARKET_CLOSE_ET = dtime(16, 0) # ── daily aggregation ──────────────────────────────────────────────────────── def _load_intraday(ticker: str, date: str, intraday_root: Path) -> pd.DataFrame | None: p = intraday_root / ticker / f"{date}.parquet" if not p.exists(): return None try: df = pd.read_parquet(p, columns=["timestamp", "open", "high", "low", "close", "volume"]) if df.empty: return None df["ts"] = pd.to_datetime(df["timestamp"], utc=True).dt.tz_convert(ET) return df.sort_values("ts").reset_index(drop=True) except Exception: return None def _aggregate_daily(df: pd.DataFrame, date: str) -> dict | None: """Aggregate intraday bars to a single daily OHLCV row (regular hours only).""" reg = df[(df["ts"].dt.time >= MARKET_OPEN_ET) & (df["ts"].dt.time < MARKET_CLOSE_ET)] if reg.empty: return None return { "date": date, "open": float(reg["open"].iloc[0]), "high": float(reg["high"].max()), "low": float(reg["low"].min()), "close": float(reg["close"].iloc[-1]), "volume": float(reg["volume"].sum()), } def _premarket_dollar_vol(df: pd.DataFrame) -> float: """Sum of (close × volume) for bars before 09:30 ET.""" pm = df[df["ts"].dt.time < MARKET_OPEN_ET] if pm.empty: return 0.0 return float((pm["close"] * pm["volume"]).sum()) # ── per-ticker processor ───────────────────────────────────────────────────── def process_ticker( ticker: str, target_dates: set[str], sorted_target: list[str], intraday_root: Path, v49_actual: set[tuple[str, str]], # {(ticker, date)} v49_trades_by_key: dict[tuple[str, str], dict], ) -> list[dict]: ticker_dir = intraday_root / ticker if not ticker_dir.exists(): return [] # collect all available dates for this ticker (sorted) all_dates = sorted( f.stem for f in ticker_dir.glob("*.parquet") if f.stem >= "2024-07-01" # load a couple months before target for ATR lookback ) if not all_dates: return [] # build daily_bars list by aggregating intraday daily_bars: list[dict] = [] daily_intraday_cache: dict[str, pd.DataFrame] = {} # cache target-range intraday for date in all_dates: df = _load_intraday(ticker, date, intraday_root) if df is None: continue daily = _aggregate_daily(df, date) if daily is None: continue daily_bars.append(daily) if date in target_dates: daily_intraday_cache[date] = df if not daily_bars: return [] rows: list[dict] = [] for i, today_bar in enumerate(daily_bars): today_date = today_bar["date"] if today_date not in target_dates: continue # lookahead-free: only bars BEFORE today prev_bars = daily_bars[:i] if len(prev_bars) < 2: continue prev_close = prev_bars[-1]["close"] if prev_bars else None if not prev_close or prev_close <= 0: continue # ATR_14 from prior 14 daily bars atr_14 = compute_atr_from_dicts(prev_bars, period=14) if atr_14 is None or atr_14 < MIN_ATR: continue risk_per_share = atr_14 * ATR_STOP_MULT # load intraday for today df = daily_intraday_cache.get(today_date) if df is None: df = _load_intraday(ticker, today_date, intraday_root) if df is None: continue # find 9:30 bar (ORB bar) and 9:35 bar (entry bar) reg_bars = df[ (df["ts"].dt.time >= MARKET_OPEN_ET) & (df["ts"].dt.time < MARKET_CLOSE_ET) ].reset_index(drop=True) if len(reg_bars) < 2: continue open_bar = reg_bars.iloc[0] # 09:30 bar entry_bar = reg_bars.iloc[1] # 09:35 bar ← synthetic entry # hygiene filter entry_price = float(entry_bar["open"]) if entry_price <= 0: continue entry_vol = float(entry_bar["volume"]) if entry_vol <= 0: continue entry_dollar_vol = entry_price * entry_vol if entry_dollar_vol < MIN_DOLLAR_VOL: continue # bars remaining to EOD from this entry bar (including itself) bars_to_eod = len(reg_bars) - 1 # bars from entry_bar.index=1 to last if bars_to_eod < MIN_BARS_TO_EOD: continue # ── computed features ────────────────────────────────────────────── today_open = float(open_bar["open"]) gap_pct = (today_open - prev_close) / prev_close if prev_close > 0 else 0.0 morning_gain_pct = gap_pct # approx: first observable return vs prev close avg_dollar_vol_30d = compute_avg_dollar_volume(prev_bars, lookback=30) or 0.0 avg_daily_vol_14d = compute_avg_daily_volume(prev_bars, lookback=14) or 0.0 # RVOL: first regular-hours bar volume vs expected (avg_daily / 78) orb_vol = float(open_bar["volume"]) expected_bar_vol = avg_daily_vol_14d / 78.0 if avg_daily_vol_14d > 0 else None rvol = (orb_vol / expected_bar_vol) if expected_bar_vol and expected_bar_vol > 0 else None # bar-level features orb_high = float(open_bar["high"]) orb_low = float(open_bar["low"]) orb_open = float(open_bar["open"]) orb_close= float(open_bar["close"]) orb_range = max(orb_high - orb_low, 1e-9) body_ratio = abs(orb_close - orb_open) / orb_range close_location= (orb_close - orb_low) / orb_range # entry bar dollar volume & first bar dollar volume first_bar_dollar_vol = orb_vol * orb_close entry_dv = entry_price * entry_vol # premarket dollar volume pm_dv = _premarket_dollar_vol(df) # ORB return: entry relative to 9:30 high orb_return = (entry_price - orb_high) / orb_high if orb_high > 0 else 0.0 # gap z-score gap_zscore_20d = ( compute_gap_zscore(prev_bars, today_open, lookback=20) if len(prev_bars) >= 21 and today_open > 0 else None ) # entropy & OBV slopes entropy_20d = 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 obv_slope_5 = compute_obv_slope_approx(prev_bars, lookback=5) if len(prev_bars) >= 7 else None # 5-day prior momentum ret_5d: float | None = None if len(prev_bars) >= 6 and prev_close and prev_close > 0: close_5d_ago = prev_bars[-5]["close"] if close_5d_ago and close_5d_ago > 0: ret_5d = (prev_close - close_5d_ago) / close_5d_ago # entry time strings (ET → naive string for downstream compat) entry_ts = pd.Timestamp(today_date, tz=ET) + pd.Timedelta(hours=9, minutes=35) exit_ts = pd.Timestamp(today_date, tz=ET) + pd.Timedelta(hours=16, minutes=0) key = (ticker, today_date) is_v49 = key in v49_actual # For V49.91 actual entries, override entry price/risk with blotter values. if is_v49: t = v49_trades_by_key[key] entry_price = float(t["entry_price"]) entry_ts = pd.Timestamp(t["entry_time"]).tz_convert(ET) exit_ts = pd.Timestamp(t["exit_time"]).tz_convert(ET) # override from blotter for accuracy risk_per_share = ( _infer_risk_from_blotter(t) if _infer_risk_from_blotter(t) else risk_per_share ) realized_r = float(t.get("r_multiple_at_exit") or 0.0) exit_reason = t.get("exit_reason", "eod") # keep computed atr_14 but use blotter passthrough fields directly for field in PASSTHROUGH_FIELDS: if field in t and t[field] is not None: pass # handled below per-field else: realized_r = float("nan") exit_reason = "eod_synthetic" row = { "ticker": ticker, "date": today_date, "entry_time": entry_ts.isoformat(), "exit_time": exit_ts.isoformat(), "entry_price": entry_price, "risk_per_share": risk_per_share, "orb_direction": "long", "r_multiple_at_exit": realized_r, "exit_reason": exit_reason, # passthrough features "atr_at_entry": atr_14, "gap_pct": gap_pct, "rvol": rvol, "morning_gain_pct": morning_gain_pct, "entropy_20d": entropy_20d, "ret_5d": ret_5d, "candidate_score": None, "score_rank_pct": None, "sector_confirmation_active": False, "sector_confirmation_score": 0.0, "entry_market_guard_active": False, "entry_market_guard_return_pct": None, "is_liquid_largecap": False, "is_moderate_gap_liquid": False, "trigger_type": "synthetic", "entry_dollar_volume": entry_dv, "avg_dollar_vol_30d": avg_dollar_vol_30d, "premarket_dollar_vol": pm_dv, "first_bar_dollar_vol": first_bar_dollar_vol, "body_ratio": body_ratio, "close_location": close_location, "gap_zscore_20d": gap_zscore_20d, "obv_slope_20": obv_slope_20, "obv_slope_5": obv_slope_5, "orb_return": orb_return, "is_v49_actual_entry": is_v49, } # For V49.91 actual entries, overwrite passthrough fields with blotter values. if is_v49: t = v49_trades_by_key[key] for field in PASSTHROUGH_FIELDS: if field in t: row[field] = t[field] row["entry_price"] = float(t["entry_price"]) row["r_multiple_at_exit"] = float(t.get("r_multiple_at_exit") or 0.0) row["exit_reason"] = t.get("exit_reason", "eod") row["trigger_type"] = t.get("trigger_type", "synthetic") row["risk_per_share"] = _infer_risk_from_blotter(t) rows.append(row) return rows PASSTHROUGH_FIELDS = [ "atr_at_entry", "gap_pct", "rvol", "morning_gain_pct", "entropy_20d", "ret_5d", "candidate_score", "score_rank_pct", "sector_confirmation_active", "sector_confirmation_score", "entry_market_guard_active", "entry_market_guard_return_pct", "is_liquid_largecap", "is_moderate_gap_liquid", "trigger_type", "entry_dollar_volume", "avg_dollar_vol_30d", "premarket_dollar_vol", "first_bar_dollar_vol", "body_ratio", "close_location", "gap_zscore_20d", "obv_slope_20", "obv_slope_5", "orb_return", ] def _infer_risk_from_blotter(t: dict) -> float: r = t.get("r_multiple_at_exit") entry = float(t["entry_price"]) exit_p = float(t["exit_price"]) direction = t.get("orb_direction", "long") if r not in (None, 0) and abs(r) > 1e-6: if direction == "long": return max((exit_p - entry) / r, 1e-6) return max((entry - exit_p) / r, 1e-6) atr = t.get("atr_at_entry") or 0.0 return max(atr * ATR_STOP_MULT, 1e-6) # ── worker wrapper ─────────────────────────────────────────────────────────── def _worker(args: tuple) -> list[dict]: ticker, target_dates_set, sorted_target, intraday_root_str, v49_actual, v49_trades_by_key = args try: return process_ticker( ticker, target_dates_set, sorted_target, Path(intraday_root_str), v49_actual, v49_trades_by_key, ) except Exception as e: print(f" [WARN] {ticker}: {e}", flush=True) return [] # ── main ───────────────────────────────────────────────────────────────────── def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--universe", default="configs/symbols_broad_snapshot_3408.yaml") ap.add_argument("--v49-run", default="tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json") ap.add_argument("--out", default="tmp/orb_phase_a_synthetic_entries") ap.add_argument("--intraday-cache", default="data/cache/intraday") ap.add_argument("--workers", type=int, default=8) ap.add_argument("--start", default=TARGET_START) ap.add_argument("--end", default=TARGET_END) args = ap.parse_args() out_dir = Path(args.out) out_dir.mkdir(parents=True, exist_ok=True) intraday_root = Path(args.intraday_cache) # load universe (yaml parses 'ON' as bool True — coerce to string) universe_data = yaml.safe_load(Path(args.universe).read_text()) raw_syms = universe_data["symbols"] # YAML 1.1 maps ON/OFF/YES/NO/TRUE/FALSE → bool; reconstruct correct tickers _BOOL_TO_TICKER = {True: "ON", False: "OFF"} tickers = [_BOOL_TO_TICKER.get(s, s) if not isinstance(s, str) else s for s in raw_syms] print(f"Universe: {len(tickers)} tickers") # filter to tickers with intraday data tickers = [t for t in tickers if (intraday_root / t).exists()] print(f"Tickers with intraday cache: {len(tickers)}") # target dates = SPY trading days in range spy_dates_all = sorted( f.stem for f in (intraday_root / "SPY").glob("*.parquet") ) target_dates = [d for d in spy_dates_all if args.start <= d <= args.end] target_dates_set = set(target_dates) print(f"Target dates: {len(target_dates)} ({target_dates[0]} → {target_dates[-1]})") # load V49.91 actual entries v49_payload = json.loads(Path(args.v49_run).read_text()) v49_trades = v49_payload["trades"] v49_actual = {(t["ticker"], t["date"]) for t in v49_trades} v49_trades_by_key = {(t["ticker"], t["date"]): t for t in v49_trades} print(f"V49.91 actual entries: {len(v49_actual)}") # prepare worker args worker_args = [ ( ticker, target_dates_set, target_dates, str(intraday_root), v49_actual, v49_trades_by_key, ) for ticker in tickers ] print(f"Processing {len(tickers)} tickers with {args.workers} workers...") all_rows: list[dict] = [] if args.workers > 1: with Pool(processes=args.workers) as pool: for i, result in enumerate(pool.imap_unordered(_worker, worker_args, chunksize=8)): all_rows.extend(result) if (i + 1) % 200 == 0: print(f" {i+1}/{len(tickers)} tickers done; {len(all_rows):,} rows so far", flush=True) else: for i, wa in enumerate(worker_args): result = _worker(wa) all_rows.extend(result) if (i + 1) % 200 == 0: print(f" {i+1}/{len(tickers)} tickers done; {len(all_rows):,} rows so far", flush=True) print(f"\nTotal rows before dedup: {len(all_rows):,}") df = pd.DataFrame(all_rows) # assign sequential trade_id df = df.sort_values(["date", "ticker"]).reset_index(drop=True) df.insert(0, "trade_id", range(len(df))) # summary stats n_v49 = df["is_v49_actual_entry"].sum() n_dates = df["date"].nunique() n_tickers = df["ticker"].nunique() print(f"Entries: {len(df):,} ({n_tickers} tickers, {n_dates} dates)") print(f"V49.91 actual entries covered: {n_v49}/{len(v49_actual)}") print(f"Missing V49.91 entries: {len(v49_actual) - n_v49}") missing = v49_actual - set(zip(df["ticker"], df["date"])) if missing: print(f" Missing: {sorted(missing)[:10]}") out_path = out_dir / "synthetic_entries.parquet" df.to_parquet(out_path, index=False) print(f"\nWrote {out_path} ({df.memory_usage(deep=True).sum() / 1e6:.1f} MB)") # summary markdown md_lines = [ "# Phase A — Synthetic Entry Dataset", "", f"**Date range:** {args.start} → {args.end} ({len(target_dates)} trading days)", f"**Universe:** {len(tickers):,} tickers with intraday data", "", f"- Total synthetic entries (after hygiene filter): **{len(df):,}**", f"- Unique tickers: **{n_tickers:,}**", f"- Unique dates: **{n_dates}**", f"- V49.91 actual entries covered: **{n_v49}/{len(v49_actual)}**", "", "## Hygiene filter applied", f"- Entry bar volume > 0", f"- Entry bar dollar volume ≥ ${MIN_DOLLAR_VOL:,}", f"- ATR_14 ≥ {MIN_ATR}", f"- Bars remaining to EOD ≥ {MIN_BARS_TO_EOD}", "", "## Entry definition", "- Entry price: open of 9:35 ET bar (second regular-hours bar)", "- Risk: ATR_14 × 0.75 (V49.91 atr_stop_multiplier)", "- Direction: long only", "", "_V49.91 actual entries use blotter entry_price/exit_time/passthrough features._", ] (out_dir / "summary.md").write_text("\n".join(md_lines)) print(f"Wrote {out_dir / 'summary.md'}") if __name__ == "__main__": main()