|
|
"""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()
|