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.
187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
"""Build a clean ORB daily-bar snapshot from Oracle daily bars.
|
|
|
|
This intentionally avoids intraday-cache fallback so sparse local intraday
|
|
fragments cannot be written as if they were full daily history.
|
|
|
|
Example:
|
|
python -m apps.intraday_bt.scripts.build_daily_snapshot \
|
|
--snapshot-id orb_daily_v49_20260508 \
|
|
--universe broad \
|
|
--start-date 2024-02-03 \
|
|
--end-date 2026-05-08 \
|
|
--force
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pyarrow.parquet as pq
|
|
import yaml
|
|
|
|
from apps.intraday_bt.oracle import make_intraday_oracle_client
|
|
from libs.common.config import get_settings
|
|
from libs.intraday.cache import DailyBarCache
|
|
from libs.intraday.screener import fetch_daily_bars_bulk
|
|
|
|
|
|
_UNIVERSE_FILES = {
|
|
"broad": "configs/symbols_broad_snapshot_3408.yaml",
|
|
"midlarge": "configs/symbols_midlarge_snapshot_exact.yaml",
|
|
"largecap": "configs/symbols.yaml",
|
|
}
|
|
|
|
_DEFAULT_EXTRA_TICKERS = ("QQQ", "SPY", "TQQQ", "QQQM", "SGOV")
|
|
|
|
|
|
def _load_tickers(universe: str, extra_tickers: list[str]) -> list[str]:
|
|
if universe in _UNIVERSE_FILES:
|
|
path = Path(_UNIVERSE_FILES[universe])
|
|
with path.open() as f:
|
|
raw = yaml.safe_load(f) or []
|
|
if isinstance(raw, dict):
|
|
tickers = raw.get("tickers") or raw.get("symbols") or []
|
|
else:
|
|
tickers = raw
|
|
else:
|
|
path = Path(universe)
|
|
with path.open() as f:
|
|
raw = yaml.safe_load(f) or []
|
|
tickers = raw.get("tickers") if isinstance(raw, dict) else raw
|
|
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for ticker in [*tickers, *extra_tickers]:
|
|
symbol = str(ticker).strip().upper()
|
|
if not symbol or symbol in seen:
|
|
continue
|
|
seen.add(symbol)
|
|
result.append(symbol)
|
|
return result
|
|
|
|
|
|
def _snapshot_summary(snapshot_dir: Path, start_date: str, end_date: str) -> dict[str, object]:
|
|
files = sorted(snapshot_dir.glob("*.parquet"))
|
|
rows_total = 0
|
|
full_end = 0
|
|
late_start = 0
|
|
examples_late: list[dict[str, object]] = []
|
|
examples_short: list[dict[str, object]] = []
|
|
for path in files:
|
|
try:
|
|
table = pq.read_table(str(path))
|
|
rows = table.to_pylist()
|
|
except Exception:
|
|
continue
|
|
if not rows:
|
|
continue
|
|
rows_total += len(rows)
|
|
first = str(rows[0]["date"])[:10]
|
|
last = str(rows[-1]["date"])[:10]
|
|
if last >= end_date:
|
|
full_end += 1
|
|
if first > start_date:
|
|
late_start += 1
|
|
if len(examples_late) < 10:
|
|
examples_late.append(
|
|
{"ticker": path.stem, "rows": len(rows), "first": first, "last": last}
|
|
)
|
|
if len(rows) < 20 and len(examples_short) < 10:
|
|
examples_short.append(
|
|
{"ticker": path.stem, "rows": len(rows), "first": first, "last": last}
|
|
)
|
|
|
|
return {
|
|
"files": len(files),
|
|
"rows_total": rows_total,
|
|
"full_end_files": full_end,
|
|
"late_start_files": late_start,
|
|
"examples_late_start": examples_late,
|
|
"examples_short": examples_short,
|
|
}
|
|
|
|
|
|
async def _run(args: argparse.Namespace) -> int:
|
|
snapshot_dir = Path(args.output_root) / args.snapshot_id
|
|
if snapshot_dir.exists():
|
|
if not args.force:
|
|
raise SystemExit(f"{snapshot_dir} already exists; use --force to replace it")
|
|
shutil.rmtree(snapshot_dir)
|
|
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
tickers = _load_tickers(args.universe, args.extra_ticker)
|
|
if args.limit is not None:
|
|
tickers = tickers[: args.limit]
|
|
|
|
print(
|
|
f"Building daily snapshot {args.snapshot_id}: {len(tickers)} tickers, "
|
|
f"{args.start_date}..{args.end_date}, concurrency={args.concurrency}"
|
|
)
|
|
|
|
started = time.time()
|
|
last_pct = [-1]
|
|
|
|
def progress(completed: int, total: int) -> None:
|
|
pct = int(completed / total * 100) if total else 100
|
|
if pct >= last_pct[0] + 2 or completed == total:
|
|
last_pct[0] = pct
|
|
sys.stdout.write(f"\r {completed}/{total} ({pct}%)")
|
|
sys.stdout.flush()
|
|
|
|
async with make_intraday_oracle_client(get_settings()) as client:
|
|
bars = await fetch_daily_bars_bulk(
|
|
tickers,
|
|
args.start_date,
|
|
args.end_date,
|
|
client,
|
|
cache=DailyBarCache(str(snapshot_dir)),
|
|
intraday_cache_fallback=None,
|
|
prefer_intraday_fallback=False,
|
|
skip_oracle_when_unhealthy=False,
|
|
concurrency=args.concurrency,
|
|
progress_callback=progress,
|
|
)
|
|
print()
|
|
|
|
summary = _snapshot_summary(snapshot_dir, args.start_date, args.end_date)
|
|
summary.update(
|
|
{
|
|
"snapshot_id": args.snapshot_id,
|
|
"universe": args.universe,
|
|
"requested_tickers": len(tickers),
|
|
"returned_tickers": len(bars),
|
|
"missing_tickers": len(tickers) - len(bars),
|
|
"start_date": args.start_date,
|
|
"end_date": args.end_date,
|
|
"elapsed_sec": round(time.time() - started, 2),
|
|
}
|
|
)
|
|
manifest_path = snapshot_dir / "_manifest.json"
|
|
manifest_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
|
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--snapshot-id", required=True)
|
|
parser.add_argument("--universe", default="broad")
|
|
parser.add_argument("--start-date", required=True)
|
|
parser.add_argument("--end-date", required=True)
|
|
parser.add_argument("--output-root", default="data/cache/daily_snapshots")
|
|
parser.add_argument("--extra-ticker", action="append", default=list(_DEFAULT_EXTRA_TICKERS))
|
|
parser.add_argument("--concurrency", type=int, default=20)
|
|
parser.add_argument("--limit", type=int, default=None)
|
|
parser.add_argument("--force", action="store_true")
|
|
args = parser.parse_args()
|
|
return asyncio.run(_run(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|