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.

167 lines
6.1 KiB
Python

"""Synthesize daily OHLCV bars from intraday 5-min cache.
Walks data/cache/intraday/{TICKER}/*.parquet and aggregates each day's
market-hours bars (09:30 ≤ t < 16:00 ET) into a single daily row.
Output: data/cache/daily_synth_full/{TICKER}.parquet — same schema as the
existing snapshots (date, open, high, low, close, volume).
Usage:
python -m apps.intraday_bt.scripts.build_daily_from_intraday \
--output-dir data/cache/daily_synth_full \
--tickers ALL # or comma-list
Skips days with fewer than --min-bars (default 50) market-hours bars.
"""
from __future__ import annotations
import argparse
import datetime as dt
import time
from pathlib import Path
from zoneinfo import ZoneInfo
import pandas as pd
_ET = ZoneInfo("America/New_York")
_INTRADAY_DIR = Path("data/cache/intraday")
_DEFAULT_OUT = Path("data/cache/daily_synth_full")
def _aggregate_one_day(df: pd.DataFrame, min_bars: int = 50) -> dict | None:
if df.empty:
return None
ts = pd.to_datetime(df["timestamp"])
if ts.iloc[0].tzinfo is None:
ts_et = ts.dt.tz_localize("UTC").dt.tz_convert(_ET)
else:
ts_et = ts.dt.tz_convert(_ET)
mask = (ts_et.dt.time >= dt.time(9, 30)) & (ts_et.dt.time < dt.time(16, 0))
m = df[mask.values]
if len(m) < min_bars:
return None
et_dates = ts_et[mask.values]
return {
"date": str(et_dates.iloc[0].date()),
"open": float(m.iloc[0]["open"]),
"high": float(m["high"].max()),
"low": float(m["low"].min()),
"close": float(m.iloc[-1]["close"]),
"volume": float(m["volume"].sum()),
}
def _build_ticker(
ticker: str,
out_dir: Path,
min_bars: int = 50,
year_filter: tuple[int, int] | None = None,
) -> tuple[int, int]:
tdir = _INTRADAY_DIR / ticker
if not tdir.is_dir():
return (0, 0)
rows: list[dict] = []
skipped = 0
files = sorted(tdir.glob("*.parquet"))
if year_filter is not None:
lo, hi = year_filter
files = [f for f in files if lo <= int(f.name[:4]) <= hi]
for f in files:
try:
df = pd.read_parquet(f, columns=["timestamp", "open", "high", "low", "close", "volume"])
except Exception:
skipped += 1
continue
agg = _aggregate_one_day(df, min_bars=min_bars)
if agg is None:
skipped += 1
continue
rows.append(agg)
if not rows:
return (0, skipped)
out_df = pd.DataFrame(rows).sort_values("date").drop_duplicates("date", keep="last")
out_path = out_dir / f"{ticker}.parquet"
out_df.to_parquet(out_path, index=False)
return (len(out_df), skipped)
def _worker(args: tuple) -> tuple[str, int, int]:
ticker, out_dir_str, min_bars, year_filter = args
rows, skipped = _build_ticker(ticker, Path(out_dir_str), min_bars, year_filter)
return (ticker, rows, skipped)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--output-dir", default=str(_DEFAULT_OUT))
ap.add_argument("--tickers", default="ALL",
help="Comma-separated ticker list, or ALL for everything in intraday cache.")
ap.add_argument("--min-bars", type=int, default=50,
help="Minimum market-hours bars to count a day (default 50).")
ap.add_argument("--limit", type=int, default=None,
help="Optional: process at most N tickers (for testing).")
ap.add_argument("--year-from", type=int, default=None,
help="Restrict to files with year >= this (e.g., 2022).")
ap.add_argument("--year-to", type=int, default=None,
help="Restrict to files with year <= this (e.g., 2024).")
ap.add_argument("--workers", type=int, default=8,
help="Parallel workers (default 8).")
args = ap.parse_args()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if args.tickers.upper() == "ALL":
tickers = sorted(d.name for d in _INTRADAY_DIR.iterdir() if d.is_dir())
else:
tickers = [t.strip().upper() for t in args.tickers.split(",") if t.strip()]
if args.limit:
tickers = tickers[: args.limit]
yf = None
if args.year_from is not None or args.year_to is not None:
yf = (args.year_from or 1900, args.year_to or 9999)
print(f"Synthesizing daily bars for {len(tickers)} tickers → {out_dir} "
f"(workers={args.workers}, year_filter={yf})")
t0 = time.time()
total_rows = 0
nonempty = 0
if args.workers <= 1:
for i, t in enumerate(tickers):
rows, _ = _build_ticker(t, out_dir, min_bars=args.min_bars, year_filter=yf)
total_rows += rows
if rows > 0:
nonempty += 1
if (i + 1) % 200 == 0 or i + 1 == len(tickers):
elapsed = time.time() - t0
rate = (i + 1) / elapsed if elapsed > 0 else 0
print(f" [{i+1}/{len(tickers)}] elapsed {elapsed:.1f}s, "
f"{rate:.1f} tk/s, {nonempty} nonempty, {total_rows} rows")
else:
from concurrent.futures import ProcessPoolExecutor, as_completed
tasks = [(t, str(out_dir), args.min_bars, yf) for t in tickers]
completed = 0
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futures = {ex.submit(_worker, task): task[0] for task in tasks}
for fut in as_completed(futures):
ticker, rows, _ = fut.result()
completed += 1
total_rows += rows
if rows > 0:
nonempty += 1
if completed % 200 == 0 or completed == len(tickers):
elapsed = time.time() - t0
rate = completed / elapsed if elapsed > 0 else 0
print(f" [{completed}/{len(tickers)}] elapsed {elapsed:.1f}s, "
f"{rate:.1f} tk/s, {nonempty} nonempty, {total_rows} rows")
print(f"\nDone. {nonempty}/{len(tickers)} tickers had at least one valid day. "
f"{total_rows} total rows. {time.time()-t0:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())