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.
fithia2/scripts/orb_synthetic_per_bar_state.py

371 lines
14 KiB
Python

"""Phase B (large dataset) — Per-Bar State Dataset from Synthetic Entries.
Reads synthetic_entries.parquet (Phase A output) and expands each entry to one
row per 5-min bar from entry through 16:00 ET. Produces the same schema as
orb_per_bar_state.py but processes ~580k entries in parallel chunks.
Output is written as per-date parquet shards that can be fed to Phase C
(orb_continuation_labels.py) after optional concatenation.
Usage:
python scripts/orb_synthetic_per_bar_state.py \
--entries tmp/orb_phase_a_synthetic_entries/synthetic_entries.parquet \
--out tmp/orb_perbar_phase_b \
--workers 8
"""
from __future__ import annotations
import argparse
import json
import os
from multiprocessing import Pool
from pathlib import Path
import numpy as np
import pandas as pd
ET = "America/New_York"
DEFAULT_INTRADAY_CACHE = Path("data/cache/intraday")
# ── helpers shared with orb_per_bar_state.py ────────────────────────────────
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
try:
df = pd.read_parquet(p)
if df.empty:
return None
df = df.sort_values("timestamp").reset_index(drop=True)
df["ts"] = pd.to_datetime(df["timestamp"], utc=True)
return df
except Exception:
return None
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:
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)
return np.where(in_session, vwap, np.nan)
def _bars_since_peak(running_peak: np.ndarray) -> np.ndarray:
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]:
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_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
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_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 build_for_entry(
trade_id: int,
row: dict,
intraday_root: Path,
spy_df: pd.DataFrame | None,
qqq_df: pd.DataFrame | None,
) -> pd.DataFrame | None:
"""Expand one synthetic entry to per-bar rows (identical logic to orb_per_bar_state.py)."""
ticker = row["ticker"]
date = row["date"]
entry_price = float(row["entry_price"])
risk = float(row["risk_per_share"])
direction = row.get("orb_direction", "long")
entry_time_raw = row["entry_time"]
exit_time_raw = row["exit_time"]
entry_time = pd.Timestamp(entry_time_raw).tz_convert("UTC") if "+" in str(entry_time_raw) or "Z" in str(entry_time_raw) else pd.Timestamp(entry_time_raw, tz=ET).tz_convert("UTC")
exit_time = pd.Timestamp(exit_time_raw ).tz_convert("UTC") if "+" in str(exit_time_raw) or "Z" in str(exit_time_raw) else pd.Timestamp(exit_time_raw, tz=ET).tz_convert("UTC")
realized_r = float(row.get("r_multiple_at_exit") or 0.0)
is_v49 = bool(row.get("is_v49_actual_entry", False))
bars = _load_intraday(ticker, date, intraday_root)
if bars is None or bars.empty:
return None
market_open_utc, market_close_utc = _market_open_close_utc(date)
bars["running_vwap"] = _running_vwap(bars, market_open_utc)
# slice from entry through EOD (ts is 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"] # already UTC
ts_utc = ts # alias for clarity
minutes_since_entry = ((ts_utc - entry_time).dt.total_seconds() / 60.0).to_numpy()
minutes_to_close = ((market_close_utc - ts_utc).dt.total_seconds() / 60.0).to_numpy()
minutes_since_open = ((ts_utc - 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()
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_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
mfe_so_far = np.maximum.accumulate(fav_high)
mae_so_far = np.minimum.accumulate(fav_low)
giveback = mfe_so_far - fav_close
bsp = _bars_since_peak(mfe_so_far)
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
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)
avg_dvol_30d = float(row.get("avg_dollar_vol_30d") or 0.0)
expected_bar_vol = (avg_dvol_30d / entry_price / 78.0) if entry_price > 0 else None
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 (ts is already UTC, passes directly)
spy_close, spy_ret_entry, spy_ret_open = _market_context_aligned(
spy_df, ts, entry_time, market_open_utc
)
qqq_close, qqq_ret_entry, qqq_ret_open = _market_context_aligned(
qqq_df, ts, entry_time, market_open_utc
)
# baseline benchmark anchors
diffs = (ts_utc - 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
is_held_by_baseline = (ts_utc <= exit_time).to_numpy()
base = {
"trade_id": trade_id,
"ticker": ticker,
"date": date,
"direction": direction,
"entry_time": entry_time,
"bar_ts": ts_utc.to_numpy(),
"bar_idx": bar_idx,
"minutes_since_entry": minutes_since_entry,
"minutes_to_close": minutes_to_close,
"minutes_since_open": minutes_since_open,
"entry_price": entry_price,
"risk_per_share": risk,
"bar_open": opens,
"bar_high": highs,
"bar_low": lows,
"bar_close": closes,
"bar_volume": vols,
"bar_return_pct": bar_return_pct,
"bar_close_loc": bar_close_loc,
"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,
"bars_since_peak": bsp,
"vwap": vwap,
"vwap_dev_pct": vwap_dev_pct,
"vol_vs_first_bar": vol_vs_first,
"vol_vs_avg_dvol30d": vol_vs_avg,
"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,
"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 if is_v49 else float("nan"),
"baseline_exit_reason": row.get("exit_reason", "eod_synthetic"),
"is_v49_actual_entry": is_v49,
}
out = pd.DataFrame(base)
for col in PASSTHROUGH_COLS:
out[col] = row.get(col)
return out
# ── worker: process one trading date ────────────────────────────────────────
def _process_date(args: tuple) -> int:
"""Process all entries for one date; append rows to a parquet shard. Returns row count."""
date, entries_for_date, intraday_root_str, out_shard_path = args
intraday_root = Path(intraday_root_str)
spy_df = _load_intraday("SPY", date, intraday_root)
qqq_df = _load_intraday("QQQ", date, intraday_root)
frames = []
for row in entries_for_date:
try:
df = build_for_entry(
int(row["trade_id"]), row, intraday_root, spy_df, qqq_df
)
if df is not None and not df.empty:
frames.append(df)
except Exception as e:
pass # skip individual failures silently
if not frames:
return 0
shard = pd.concat(frames, ignore_index=True)
shard.to_parquet(out_shard_path, index=False)
return len(shard)
# ── main ─────────────────────────────────────────────────────────────────────
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--entries", required=True, help="Phase A synthetic_entries.parquet")
ap.add_argument("--out", required=True)
ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE))
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--concat", action="store_true",
help="After shards are written, concat into one big parquet")
args = ap.parse_args()
out_dir = Path(args.out)
shards_dir = out_dir / "shards"
shards_dir.mkdir(parents=True, exist_ok=True)
intraday_root = Path(args.intraday_cache)
entries = pd.read_parquet(args.entries)
print(f"Loaded {len(entries):,} entries from {entries['date'].nunique()} dates")
entries["is_v49_actual_entry"] = entries["is_v49_actual_entry"].fillna(False)
# group by date
dates = sorted(entries["date"].unique())
date_groups: list[tuple] = []
for date in dates:
grp = entries[entries["date"] == date].to_dict("records")
shard_path = shards_dir / f"{date}.parquet"
if shard_path.exists():
continue # resume support: skip already-done dates
date_groups.append((date, grp, str(intraday_root), str(shard_path)))
print(f"Dates to process: {len(date_groups)} (skipping {len(dates) - len(date_groups)} cached)")
total_rows = 0
done = 0
if args.workers > 1:
with Pool(processes=args.workers) as pool:
for n_rows in pool.imap_unordered(_process_date, date_groups, chunksize=1):
total_rows += n_rows
done += 1
if done % 20 == 0 or done == len(date_groups):
print(f" {done}/{len(date_groups)} dates {total_rows:,} rows", flush=True)
else:
for args_tuple in date_groups:
n_rows = _process_date(args_tuple)
total_rows += n_rows
done += 1
if done % 20 == 0 or done == len(date_groups):
print(f" {done}/{len(date_groups)} dates {total_rows:,} rows", flush=True)
print(f"\nShard writing done: {total_rows:,} rows across {len(dates)} dates")
# optionally concat all shards into one file
if args.concat:
print("Concatenating shards...")
shard_files = sorted(shards_dir.glob("*.parquet"))
all_frames = [pd.read_parquet(f) for f in shard_files]
if all_frames:
big = pd.concat(all_frames, ignore_index=True)
out_path = out_dir / "per_bar_states.parquet"
big.to_parquet(out_path, index=False)
print(f"Wrote {out_path} ({len(big):,} rows, {big.shape[1]} columns)")
if __name__ == "__main__":
main()