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.
371 lines
14 KiB
Python
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()
|