"""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", } _INTRADAY_KIND_KEY = b"intraday_cache_kind" _INTRADAY_KIND_POSITIVE = b"bars" _INTRADAY_KIND_NEGATIVE = b"negative" _INTRADAY_NEGATIVE_REASON_KEY = b"intraday_negative_reason" _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, _INTRADAY_KIND_KEY: _INTRADAY_KIND_POSITIVE, }) _MIN_VALID_INTRADAY_ROWS = 10 _DAILY_CACHE_STATIC_METADATA = { b"daily_cache_version": b"1", b"daily_cache_source": b"api_v1_price_data", b"daily_cache_interval": b"1d", } _DAILY_COVERAGE_START_KEY = b"daily_cache_coverage_start" _DAILY_COVERAGE_END_KEY = b"daily_cache_coverage_end" _DAILY_SCHEMA = pa.schema([ pa.field("date", 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()), ]) _DAILY_REQUIRED_COLUMNS = {"date", "open", "high", "low", "close", "volume"} def _daily_rows_match_coverage_sanity( rows: list[dict[str, Any]], coverage_start: str | None, coverage_end: str | None, ) -> bool: """Reject egregiously partial daily caches that claim much wider coverage. Small mismatches are expected around weekends / holidays because the cache stores calendar coverage while the rows only contain trading sessions. Large gaps, however, usually indicate a truncated bulk Oracle response that should not be reused for feature warmup. """ if not rows or coverage_start is None or coverage_end is None: return True try: from datetime import date as _date first_row = _date.fromisoformat(str(rows[0]["date"])[:10]) last_row = _date.fromisoformat(str(rows[-1]["date"])[:10]) coverage_start_dt = _date.fromisoformat(coverage_start) coverage_end_dt = _date.fromisoformat(coverage_end) except Exception: return True if (first_row - coverage_start_dt).days > 10: return False if (coverage_end_dt - last_row).days > 10: return False return True 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_status(path: Path) -> str | None: try: meta = pq.read_metadata(str(path)) arrow_schema = meta.schema.to_arrow_schema() if not _REQUIRED_COLUMNS.issubset(set(arrow_schema.names)): return None schema_meta = arrow_schema.metadata or {} if not all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items()): return None kind = schema_meta.get(_INTRADAY_KIND_KEY, _INTRADAY_KIND_POSITIVE) if kind == _INTRADAY_KIND_NEGATIVE: return "negative" if meta.num_rows == 0 else None if kind not in (_INTRADAY_KIND_POSITIVE, None): return None return "positive" if meta.num_rows >= _MIN_VALID_INTRADAY_ROWS else None except Exception: return None @staticmethod def is_complete_enough(bars: list[dict[str, Any]]) -> bool: """Heuristic: keep only intraday responses with enough bars to be useful.""" return len(bars) >= _MIN_VALID_INTRADAY_ROWS 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_status(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 status = self._metadata_status(p) if not status: p.unlink(missing_ok=True) return None if status == "negative": return [] try: table = pq.read_table(str(p)) 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 put_negative(self, ticker: str, date: str, reason: str = "empty_or_sparse") -> None: """Cache a stable empty/sparse response to avoid repeating futile API calls.""" p = self._path(ticker, date) p.parent.mkdir(parents=True, exist_ok=True) schema = _SCHEMA.with_metadata({ **_CACHE_METADATA, _INTRADAY_KIND_KEY: _INTRADAY_KIND_NEGATIVE, _INTRADAY_NEGATIVE_REASON_KEY: reason.encode(), }) table = pa.Table.from_pylist([], schema=schema) tmp = p.with_suffix(".tmp") try: pq.write_table(table, str(tmp), compression="snappy") os.replace(str(tmp), str(p)) 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 available_dates( self, ticker: str, start_date: str | None = None, end_date: str | None = None, ) -> list[str]: """List cache dates for one ticker within an optional date range. Invalid cache files are discarded as they are encountered. Negative cache entries are included so callers can preserve the original trading-day shape while deciding how to handle empty responses. """ ticker_dir = self._root / ticker.upper() if not ticker_dir.exists(): return [] dates: list[str] = [] for path in ticker_dir.glob("*.parquet"): date_str = path.stem if start_date and date_str < start_date: continue if end_date and date_str > end_date: continue if not self._metadata_status(path): path.unlink(missing_ok=True) continue dates.append(date_str) return sorted(dates) 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, } class DailyBarCache: """Disk-based cache for daily OHLCV bars using one Parquet file per ticker.""" def __init__(self, cache_dir: str = "data/cache/daily") -> None: self._root = Path(cache_dir) def _path(self, ticker: str) -> Path: return self._root / f"{ticker.upper()}.parquet" @staticmethod def _read_schema_metadata(path: Path) -> dict[bytes, bytes] | None: try: meta = pq.read_metadata(str(path)) if meta.num_rows < 0: return None return meta.schema.to_arrow_schema().metadata or {} except Exception: return None @classmethod def _metadata_valid(cls, 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 _DAILY_REQUIRED_COLUMNS.issubset(set(arrow_schema.names)): return False schema_meta = arrow_schema.metadata or {} if not all(schema_meta.get(k) == v for k, v in _DAILY_CACHE_STATIC_METADATA.items()): return False return ( schema_meta.get(_DAILY_COVERAGE_START_KEY) is not None and schema_meta.get(_DAILY_COVERAGE_END_KEY) is not None ) except Exception: return False @classmethod def _coverage_from_metadata(cls, path: Path) -> tuple[str | None, str | None]: schema_meta = cls._read_schema_metadata(path) or {} start = schema_meta.get(_DAILY_COVERAGE_START_KEY) end = schema_meta.get(_DAILY_COVERAGE_END_KEY) return ( start.decode() if start else None, end.decode() if end else None, ) @staticmethod def _normalize_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for b in rows: normalized.append( { "date": str(b.get("date", ""))[:10], "open": float(b.get("open", 0.0) or 0.0), "high": float(b.get("high", 0.0) or 0.0), "low": float(b.get("low", 0.0) or 0.0), "close": float(b.get("close", 0.0) or 0.0), "volume": float(b.get("volume", 0.0) or 0.0), } ) return normalized def get( self, ticker: str, start_date: str, end_date: str, ) -> list[dict[str, Any]] | None: """Read cached daily bars for an exact date range if covered.""" p = self._path(ticker) if not p.exists(): return None if not self._metadata_valid(p): p.unlink(missing_ok=True) return None coverage_start, coverage_end = self._coverage_from_metadata(p) if coverage_start is None or coverage_end is None: p.unlink(missing_ok=True) return None if start_date < coverage_start or end_date > coverage_end: return None try: table = pq.read_table( str(p), filters=[ ("date", ">=", start_date), ("date", "<=", end_date), ], ) rows = self._normalize_rows(table.to_pylist()) if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end): p.unlink(missing_ok=True) return None return rows except Exception: return None def get_with_tail( self, ticker: str, start_date: str, end_date: str, ) -> tuple[list[dict[str, Any]] | None, str | None]: """Like get(), but supports partial hits when end_date > coverage_end. Returns: (bars, None) — full hit (end_date <= coverage_end) (bars, tail_start) — partial hit; bars cover start_date..coverage_end, tail_start is the first calendar day to fetch from Oracle (None, None) — true miss (no file, or start_date not covered) """ from datetime import date as _date, timedelta as _td p = self._path(ticker) if not p.exists(): return None, None if not self._metadata_valid(p): p.unlink(missing_ok=True) return None, None coverage_start, coverage_end = self._coverage_from_metadata(p) if coverage_start is None or coverage_end is None: p.unlink(missing_ok=True) return None, None if start_date < coverage_start: return None, None # true miss: need earlier data than cached effective_end = coverage_end if end_date > coverage_end else end_date try: table = pq.read_table( str(p), filters=[("date", ">=", start_date), ("date", "<=", effective_end)], ) rows = self._normalize_rows(table.to_pylist()) except Exception: return None, None if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end): p.unlink(missing_ok=True) return None, None if end_date <= coverage_end: return rows, None # full hit # Partial hit — caller must fetch from tail_start to end_date tail_start = (_date.fromisoformat(coverage_end) + _td(days=1)).isoformat() return rows, tail_start def put( self, ticker: str, start_date: str, end_date: str, bars: list[dict[str, Any]], ) -> None: """Merge a fetched daily-bar range into the ticker cache.""" p = self._path(ticker) p.parent.mkdir(parents=True, exist_ok=True) normalized_new = self._normalize_rows(bars) merged_by_date: dict[str, dict[str, Any]] = {} coverage_start = start_date coverage_end = end_date if p.exists() and self._metadata_valid(p): try: existing = pq.read_table(str(p)).to_pylist() for row in self._normalize_rows(existing): merged_by_date[row["date"]] = row existing_start, existing_end = self._coverage_from_metadata(p) if existing_start: coverage_start = min(coverage_start, existing_start) if existing_end: coverage_end = max(coverage_end, existing_end) except Exception: p.unlink(missing_ok=True) merged_by_date = {} elif p.exists(): p.unlink(missing_ok=True) for row in normalized_new: merged_by_date[row["date"]] = row rows = [merged_by_date[d] for d in sorted(merged_by_date)] metadata = dict(_DAILY_CACHE_STATIC_METADATA) metadata[_DAILY_COVERAGE_START_KEY] = coverage_start.encode() metadata[_DAILY_COVERAGE_END_KEY] = coverage_end.encode() schema = _DAILY_SCHEMA.with_metadata(metadata) table = pa.Table.from_pylist(rows, schema=schema) tmp = p.with_suffix(".tmp") try: pq.write_table(table, str(tmp), compression="snappy") os.replace(str(tmp), str(p)) except Exception: if tmp.exists(): tmp.unlink(missing_ok=True) raise