|
|
"""Disk-based Parquet cache for intraday bar data.
|
|
|
|
|
|
Layout: {cache_dir}/{TICKER}/{YYYY-MM-DD}.parquet
|
|
|
|
|
|
Each file stores one trading day of 5-minute bars for one ticker.
|
|
|
~78 bars × 7 columns ≈ 2-3 KB per file (snappy compressed).
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import os
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
|
|
|
import pyarrow as pa
|
|
|
import pyarrow.parquet as pq
|
|
|
|
|
|
|
|
|
_CACHE_METADATA = {
|
|
|
b"intraday_cache_version": b"3", # v3: SIP data (v2 was IEX ~2.5% tape)
|
|
|
b"intraday_cache_source": b"api_v1_alpaca_intraday",
|
|
|
b"intraday_cache_interval": b"5min",
|
|
|
}
|
|
|
|
|
|
_SCHEMA = pa.schema([
|
|
|
pa.field("timestamp", pa.string()),
|
|
|
pa.field("open", pa.float64()),
|
|
|
pa.field("high", pa.float64()),
|
|
|
pa.field("low", pa.float64()),
|
|
|
pa.field("close", pa.float64()),
|
|
|
pa.field("volume", pa.float64()),
|
|
|
pa.field("vwap", pa.float64()),
|
|
|
])
|
|
|
_REQUIRED_COLUMNS = {"timestamp", "open", "high", "low", "close", "volume"}
|
|
|
_SCHEMA_WITH_METADATA = _SCHEMA.with_metadata(_CACHE_METADATA)
|
|
|
|
|
|
class IntradayCache:
|
|
|
"""Disk-based cache for 5-minute intraday bars using Parquet.
|
|
|
|
|
|
Thread-safe for reads; uses atomic write (tmp → rename) for writes.
|
|
|
"""
|
|
|
|
|
|
def __init__(self, cache_dir: str = "data/cache/intraday") -> None:
|
|
|
self._root = Path(cache_dir)
|
|
|
|
|
|
def _path(self, ticker: str, date: str) -> Path:
|
|
|
return self._root / ticker.upper() / f"{date}.parquet"
|
|
|
|
|
|
@staticmethod
|
|
|
def _metadata_valid(path: Path) -> bool:
|
|
|
try:
|
|
|
meta = pq.read_metadata(str(path))
|
|
|
if meta.num_rows <= 0:
|
|
|
return False
|
|
|
arrow_schema = meta.schema.to_arrow_schema()
|
|
|
if not _REQUIRED_COLUMNS.issubset(set(arrow_schema.names)):
|
|
|
return False
|
|
|
schema_meta = arrow_schema.metadata or {}
|
|
|
return all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items())
|
|
|
except Exception:
|
|
|
return False
|
|
|
|
|
|
def has(self, ticker: str, date: str) -> bool:
|
|
|
"""Return True if cached bars exist for ticker on date."""
|
|
|
p = self._path(ticker, date)
|
|
|
if not p.exists():
|
|
|
return False
|
|
|
if not self._metadata_valid(p):
|
|
|
p.unlink(missing_ok=True)
|
|
|
return False
|
|
|
return True
|
|
|
|
|
|
def get(self, ticker: str, date: str) -> list[dict[str, Any]] | None:
|
|
|
"""Read cached bars. Returns None on cache miss or read error."""
|
|
|
p = self._path(ticker, date)
|
|
|
if not p.exists():
|
|
|
return None
|
|
|
if not self._metadata_valid(p):
|
|
|
p.unlink(missing_ok=True)
|
|
|
return None
|
|
|
try:
|
|
|
table = pq.read_table(str(p))
|
|
|
if table.num_rows == 0:
|
|
|
return None
|
|
|
return table.to_pylist()
|
|
|
except Exception:
|
|
|
return None
|
|
|
|
|
|
def put(self, ticker: str, date: str, bars: list[dict[str, Any]]) -> None:
|
|
|
"""Write bars to cache using atomic write (tmp → rename).
|
|
|
|
|
|
Silently skips if bars is empty.
|
|
|
"""
|
|
|
if not bars:
|
|
|
return
|
|
|
|
|
|
p = self._path(ticker, date)
|
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# Normalize bars to match schema
|
|
|
rows = []
|
|
|
for b in bars:
|
|
|
rows.append({
|
|
|
"timestamp": str(b.get("timestamp", "")),
|
|
|
"open": float(b.get("open", 0.0)),
|
|
|
"high": float(b.get("high", 0.0)),
|
|
|
"low": float(b.get("low", 0.0)),
|
|
|
"close": float(b.get("close", 0.0)),
|
|
|
"volume": float(b.get("volume", 0.0)),
|
|
|
"vwap": float(b.get("vwap", 0.0)) if b.get("vwap") is not None else 0.0,
|
|
|
})
|
|
|
|
|
|
table = pa.Table.from_pylist(rows, schema=_SCHEMA_WITH_METADATA)
|
|
|
tmp = p.with_suffix(".tmp")
|
|
|
try:
|
|
|
pq.write_table(table, str(tmp), compression="snappy")
|
|
|
os.replace(str(tmp), str(p)) # atomic on POSIX
|
|
|
except Exception:
|
|
|
if tmp.exists():
|
|
|
tmp.unlink(missing_ok=True)
|
|
|
raise
|
|
|
|
|
|
def evict(
|
|
|
self,
|
|
|
ticker: str | None = None,
|
|
|
before_date: str | None = None,
|
|
|
) -> int:
|
|
|
"""Remove cached files. Returns count of files removed.
|
|
|
|
|
|
Args:
|
|
|
ticker: If set, only evict this ticker's files.
|
|
|
before_date: If set, only evict files for dates < this (YYYY-MM-DD).
|
|
|
"""
|
|
|
removed = 0
|
|
|
if ticker:
|
|
|
ticker_dir = self._root / ticker.upper()
|
|
|
if not ticker_dir.exists():
|
|
|
return 0
|
|
|
dirs_to_scan = [ticker_dir]
|
|
|
else:
|
|
|
if not self._root.exists():
|
|
|
return 0
|
|
|
dirs_to_scan = [d for d in self._root.iterdir() if d.is_dir()]
|
|
|
|
|
|
for d in dirs_to_scan:
|
|
|
for f in d.glob("*.parquet"):
|
|
|
if before_date and f.stem >= before_date:
|
|
|
continue
|
|
|
f.unlink(missing_ok=True)
|
|
|
removed += 1
|
|
|
|
|
|
return removed
|
|
|
|
|
|
def stats(self) -> dict[str, Any]:
|
|
|
"""Return cache statistics."""
|
|
|
if not self._root.exists():
|
|
|
return {"total_files": 0, "total_bytes": 0, "tickers": 0}
|
|
|
|
|
|
total_files = 0
|
|
|
total_bytes = 0
|
|
|
tickers = set()
|
|
|
dates: list[str] = []
|
|
|
|
|
|
for ticker_dir in self._root.iterdir():
|
|
|
if not ticker_dir.is_dir():
|
|
|
continue
|
|
|
tickers.add(ticker_dir.name)
|
|
|
for f in ticker_dir.glob("*.parquet"):
|
|
|
total_files += 1
|
|
|
total_bytes += f.stat().st_size
|
|
|
dates.append(f.stem)
|
|
|
|
|
|
return {
|
|
|
"total_files": total_files,
|
|
|
"total_bytes": total_bytes,
|
|
|
"total_mb": round(total_bytes / 1_048_576, 2),
|
|
|
"tickers": len(tickers),
|
|
|
"date_min": min(dates) if dates else None,
|
|
|
"date_max": max(dates) if dates else None,
|
|
|
}
|