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.

180 lines
5.8 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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,
}