You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

427 lines
16 KiB
Python

"""ORB Per-Bar State Dataset Builder.
For each entry in a V49.91 ORB intraday backtest run, expand to one row per
5-min bar from entry through 16:00 ET (regardless of when V49.91's rule
exited — the ML model may decide to hold longer or exit earlier).
Each row is a "decision point" with state features available at that time.
The continuation-value labels are computed in Phase C; this script only
prepares features.
Usage:
python scripts/orb_per_bar_state.py \
--run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \
--out tmp/orb_perbar_v49_91_200d
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
ET = "America/New_York"
DEFAULT_INTRADAY_CACHE = Path("data/cache/intraday")
DEFAULT_ATR_STOP_MULT = 0.75
def _infer_risk_per_share(t: dict) -> float:
r = t.get("r_multiple_at_exit")
entry = t["entry_price"]
exit_p = t["exit_price"]
direction = t.get("orb_direction", "long")
if r not in (None, 0) and abs(r) > 1e-6:
if direction == "long":
return (exit_p - entry) / r
return (entry - exit_p) / r
atr = t.get("atr_at_entry") or 0.0
return max(atr * DEFAULT_ATR_STOP_MULT, 1e-6)
def _load_intraday(ticker: str, date: str, cache_root: Path) -> pd.DataFrame | None:
p = cache_root / ticker / f"{date}.parquet"
if not p.exists():
return None
df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
df["ts"] = pd.to_datetime(df["timestamp"], utc=True)
return df
def _market_open_close_utc(date_str: str) -> tuple[pd.Timestamp, pd.Timestamp]:
d = pd.Timestamp(date_str, tz=ET)
open_et = d + pd.Timedelta(hours=9, minutes=30)
close_et = d + pd.Timedelta(hours=16)
return open_et.tz_convert("UTC"), close_et.tz_convert("UTC")
def _running_vwap(df: pd.DataFrame, market_open_utc: pd.Timestamp) -> np.ndarray:
"""Compute running VWAP from market open onward; bars before open get NaN."""
typical = (df["high"] + df["low"] + df["close"]) / 3
vol = df["volume"].astype(float)
in_session = df["ts"] >= market_open_utc
pv = np.where(in_session, typical * vol, 0.0)
v = np.where(in_session, vol, 0.0)
cum_pv = np.cumsum(pv)
cum_v = np.cumsum(v)
with np.errstate(divide="ignore", invalid="ignore"):
vwap = np.where(cum_v > 0, cum_pv / cum_v, np.nan)
vwap = np.where(in_session, vwap, np.nan)
return vwap
def _bars_since_peak(running_peak: np.ndarray) -> np.ndarray:
"""For each i, how many bars ago was the most recent strict new high in running_peak."""
n = len(running_peak)
out = np.zeros(n, dtype=np.int32)
last_peak_idx = 0
last_peak = running_peak[0]
for i in range(n):
if running_peak[i] > last_peak:
last_peak = running_peak[i]
last_peak_idx = i
out[i] = i - last_peak_idx
return out
def _market_context_aligned(
market_df: pd.DataFrame | None,
target_ts: pd.DatetimeIndex,
entry_ts: pd.Timestamp,
market_open: pd.Timestamp,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Align market index closes to target bar timestamps via asof; compute returns since entry / open."""
n = len(target_ts)
if market_df is None or market_df.empty:
nan = np.full(n, np.nan)
return nan, nan.copy(), nan.copy()
m = market_df.sort_values("ts").reset_index(drop=True)
target = pd.DataFrame({"ts": target_ts})
aligned = pd.merge_asof(target, m[["ts", "close"]], on="ts", direction="backward")
closes = aligned["close"].to_numpy()
# Entry-time close & open-time close from the same series
entry_close = (
m[m["ts"] <= entry_ts]["close"].iloc[-1] if (m["ts"] <= entry_ts).any() else np.nan
)
open_close = (
m[m["ts"] <= market_open]["close"].iloc[-1] if (m["ts"] <= market_open).any()
else (m["close"].iloc[0] if len(m) else np.nan)
)
ret_since_entry = closes / entry_close - 1.0 if pd.notna(entry_close) else np.full(n, np.nan)
ret_since_open = closes / open_close - 1.0 if pd.notna(open_close) else np.full(n, np.nan)
return closes, ret_since_entry, ret_since_open
# Trade-level passthrough columns (broadcast across all bars of that trade).
PASSTHROUGH_COLS = [
"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_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",
"is_liquid_largecap",
"is_moderate_gap_liquid",
"entry_market_guard_active",
"entry_market_guard_return_pct",
"trigger_type",
]
def build_for_trade(
trade_id: int,
t: dict,
intraday_root: Path,
spy_by_date: dict[str, pd.DataFrame],
qqq_by_date: dict[str, pd.DataFrame],
) -> pd.DataFrame | None:
ticker = t["ticker"]
date = t["date"]
bars = _load_intraday(ticker, date, intraday_root)
if bars is None or bars.empty:
return None
entry_time = pd.Timestamp(t["entry_time"]).tz_convert("UTC")
exit_time = pd.Timestamp(t["exit_time"]).tz_convert("UTC")
entry_price = float(t["entry_price"])
direction = t.get("orb_direction", "long")
risk = _infer_risk_per_share(t)
realized_r = float(t.get("r_multiple_at_exit") or 0.0)
market_open_utc, market_close_utc = _market_open_close_utc(date)
# Compute running VWAP on full-day bars (so it accumulates correctly), then slice.
bars["running_vwap"] = _running_vwap(bars, market_open_utc)
win = bars[(bars["ts"] >= entry_time) & (bars["ts"] <= market_close_utc)].reset_index(drop=True)
if win.empty:
return None
n = len(win)
bar_idx = np.arange(n, dtype=np.int32)
ts = win["ts"]
minutes_since_entry = ((ts - entry_time).dt.total_seconds() / 60.0).to_numpy()
minutes_to_close = ((market_close_utc - ts).dt.total_seconds() / 60.0).to_numpy()
minutes_since_open = ((ts - market_open_utc).dt.total_seconds() / 60.0).to_numpy()
opens = win["open"].to_numpy()
highs = win["high"].to_numpy()
lows = win["low"].to_numpy()
closes = win["close"].to_numpy()
vols = win["volume"].to_numpy().astype(float)
vwap = win["running_vwap"].to_numpy()
# Favorable-direction R-multiples per bar (long: price-entry; short: entry-price).
if direction == "long":
fav_high = (highs - entry_price) / risk
fav_low = (lows - entry_price) / risk
fav_close = (closes - entry_price) / risk
else:
fav_high = (entry_price - lows) / risk
fav_low = (entry_price - highs) / risk
fav_close = (entry_price - closes) / risk
# Next-bar open R: actionable execution price if you decide at this bar's close.
next_open_price = np.append(opens[1:], closes[-1])
if direction == "long":
fav_next_open = (next_open_price - entry_price) / risk
else:
fav_next_open = (entry_price - next_open_price) / risk
# Running MFE / MAE / peak (vectorized over bars).
mfe_so_far = np.maximum.accumulate(fav_high)
mae_so_far = np.minimum.accumulate(fav_low)
giveback_from_peak = mfe_so_far - fav_close
bars_since_peak = _bars_since_peak(mfe_so_far)
# Bar dynamics.
bar_return_pct = np.where(opens > 0, (closes - opens) / opens, np.nan)
bar_range = np.maximum(highs - lows, 1e-9)
bar_close_loc = (closes - lows) / bar_range # 0 = closed at low, 1 = closed at high
# Liquidity / VWAP.
with np.errstate(divide="ignore", invalid="ignore"):
vwap_dev_pct = np.where((vwap > 0) & np.isfinite(vwap), (closes - vwap) / vwap, np.nan)
first_vol = vols[0] if vols[0] > 0 else np.nan
vol_vs_first = vols / first_vol if pd.notna(first_vol) and first_vol > 0 else np.full(n, np.nan)
# Bar volume vs avg-daily expectation: avg_daily_dollar_volume / entry_price / 78 bars.
avg_dvol_30d = t.get("avg_dollar_vol_30d") or 0.0
expected_bar_vol = (avg_dvol_30d / entry_price / 78.0) if entry_price > 0 else np.nan
if expected_bar_vol and expected_bar_vol > 0 and np.isfinite(expected_bar_vol):
vol_vs_avg = vols / expected_bar_vol
else:
vol_vs_avg = np.full(n, np.nan)
# Market context (SPY, QQQ).
spy_close, spy_ret_entry, spy_ret_open = _market_context_aligned(
spy_by_date.get(date), ts, entry_time, market_open_utc
)
qqq_close, qqq_ret_entry, qqq_ret_open = _market_context_aligned(
qqq_by_date.get(date), ts, entry_time, market_open_utc
)
# Baseline benchmark anchors (reference, not features).
is_held_by_baseline = (ts <= exit_time).to_numpy()
baseline_exit_at_or_before = (ts >= exit_time).to_numpy()
# Index of bar nearest to (>=) baseline exit_time.
diffs = (ts - exit_time).dt.total_seconds().to_numpy()
if (diffs >= 0).any():
baseline_exit_bar_idx_val = int(np.argmax(diffs >= 0))
else:
baseline_exit_bar_idx_val = n - 1
# Build the per-bar DataFrame.
base = {
# ID
"trade_id": trade_id,
"ticker": ticker,
"date": date,
"direction": direction,
"entry_time": entry_time,
"bar_ts": ts.to_numpy(),
"bar_idx": bar_idx,
# Time
"minutes_since_entry": minutes_since_entry,
"minutes_to_close": minutes_to_close,
"minutes_since_open": minutes_since_open,
# Trade-level static
"entry_price": entry_price,
"risk_per_share": risk,
# Bar OHLCV
"bar_open": opens,
"bar_high": highs,
"bar_low": lows,
"bar_close": closes,
"bar_volume": vols,
# Bar dynamics
"bar_return_pct": bar_return_pct,
"bar_close_loc": bar_close_loc,
# Trade R state
"current_close_r": fav_close,
"current_high_r": fav_high,
"current_low_r": fav_low,
"next_open_r": fav_next_open,
"mfe_so_far_r": mfe_so_far,
"mae_so_far_r": mae_so_far,
"giveback_from_peak_r": giveback_from_peak,
"bars_since_peak": bars_since_peak,
# Liquidity
"vwap": vwap,
"vwap_dev_pct": vwap_dev_pct,
"vol_vs_first_bar": vol_vs_first,
"vol_vs_avg_dvol30d": vol_vs_avg,
# Market context
"spy_close": spy_close,
"spy_return_since_entry": spy_ret_entry,
"spy_return_since_open": spy_ret_open,
"qqq_close": qqq_close,
"qqq_return_since_entry": qqq_ret_entry,
"qqq_return_since_open": qqq_ret_open,
# Baseline benchmark anchors (NOT features for training; used in eval)
"is_held_by_baseline": is_held_by_baseline,
"baseline_exit_bar_idx": baseline_exit_bar_idx_val,
"baseline_exit_time": exit_time,
"baseline_realized_r": realized_r,
"baseline_exit_reason": t.get("exit_reason"),
}
out = pd.DataFrame(base)
# Broadcast trade-level passthrough columns (entry-time-known features).
for col in PASSTHROUGH_COLS:
out[col] = t.get(col)
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--run", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE))
args = ap.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
intraday_root = Path(args.intraday_cache)
payload = json.loads(Path(args.run).read_text())
trades = payload["trades"]
print(f"Loaded {len(trades)} trades")
# Pre-load SPY / QQQ once per unique trade date.
unique_dates = sorted({t["date"] for t in trades})
spy_by_date: dict[str, pd.DataFrame] = {}
qqq_by_date: dict[str, pd.DataFrame] = {}
for d in unique_dates:
s = _load_intraday("SPY", d, intraday_root)
q = _load_intraday("QQQ", d, intraday_root)
if s is not None:
spy_by_date[d] = s
if q is not None:
qqq_by_date[d] = q
print(f"Loaded SPY for {len(spy_by_date)}/{len(unique_dates)} dates, QQQ for {len(qqq_by_date)}")
frames = []
skipped = 0
for i, t in enumerate(trades):
try:
df = build_for_trade(i, t, intraday_root, spy_by_date, qqq_by_date)
if df is None or df.empty:
skipped += 1
continue
frames.append(df)
except Exception as e:
print(f" trade {i} {t.get('ticker')} {t.get('date')}: {e}")
skipped += 1
if (i + 1) % 25 == 0:
print(f" processed {i+1}/{len(trades)}")
if not frames:
raise SystemExit("No trades produced rows")
big = pd.concat(frames, ignore_index=True)
parquet = out_dir / "per_bar_states.parquet"
big.to_parquet(parquet, index=False)
print(
f"\nWrote {parquet} | trades: {big['trade_id'].nunique()} | "
f"rows: {len(big):,} | columns: {big.shape[1]} | skipped: {skipped}"
)
# Quick sanity report.
rep_path = out_dir / "summary.md"
rep = build_summary(big, payload.get("metrics", {}))
rep_path.write_text(rep)
print(f"Wrote {rep_path}")
def build_summary(df: pd.DataFrame, run_meta: dict) -> str:
n_trades = df["trade_id"].nunique()
n_rows = len(df)
bars_per_trade = df.groupby("trade_id").size()
cov = {
"spy_return_since_entry": df["spy_return_since_entry"].notna().mean(),
"qqq_return_since_entry": df["qqq_return_since_entry"].notna().mean(),
"vwap_dev_pct": df["vwap_dev_pct"].notna().mean(),
"vol_vs_avg_dvol30d": df["vol_vs_avg_dvol30d"].notna().mean(),
}
lines = [
"# Per-Bar State Dataset",
"",
f"**Run:** `{run_meta.get('run_id')}` "
f"({run_meta.get('start_date')}{run_meta.get('end_date')}, "
f"{run_meta.get('total_trades')} entries)",
"",
f"- Trades expanded: **{n_trades}**",
f"- Total decision-point rows: **{n_rows:,}**",
f"- Bars per trade: median {int(bars_per_trade.median())}, "
f"min {int(bars_per_trade.min())}, max {int(bars_per_trade.max())}",
f"- Schema: **{df.shape[1]}** columns",
"",
"## Feature coverage (non-null fraction)",
"",
"| feature | coverage |",
"|---|---|",
]
for k, v in cov.items():
lines.append(f"| {k} | {v:.1%} |")
lines += [
"",
"## Sanity distributions (random sample of decision points)",
"",
f"- current_close_r quartiles: {df['current_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
f"- mfe_so_far_r quartiles: {df['mfe_so_far_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
f"- mae_so_far_r quartiles: {df['mae_so_far_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
f"- giveback_from_peak_r: {df['giveback_from_peak_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
f"- vwap_dev_pct: {df['vwap_dev_pct'].quantile([0.1,0.25,0.5,0.75,0.9]).round(4).to_dict()}",
f"- spy_return_since_entry: {df['spy_return_since_entry'].quantile([0.1,0.25,0.5,0.75,0.9]).round(4).to_dict()}",
"",
"## Held vs post-baseline-exit bars",
"",
f"- Bars while baseline still held: **{int(df['is_held_by_baseline'].sum()):,}** "
f"({df['is_held_by_baseline'].mean():.1%})",
f"- Bars after baseline exited (extension data, ML may use to learn 'hold longer'): "
f"**{int((~df['is_held_by_baseline']).sum()):,}**",
"",
]
return "\n".join(lines)
if __name__ == "__main__":
main()