Coverage for src/finbot/market/cache.py: 96%
169 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
1import hashlib
2import json
3import logging
4import shutil
5import tempfile
6from collections.abc import Mapping, Sequence
7from datetime import datetime
8from pathlib import Path
10from finbot.market.dataset import DatasetMetadata
11from finbot.market.models import Bar
13logger = logging.getLogger(__name__)
15CACHE_FORMAT_VERSION = 1
16DEFAULT_CACHE_DIR = Path(".finbot/cache")
19class DatasetCacheError(RuntimeError):
20 """Base error for historical dataset cache failures."""
23class DatasetNotFoundError(DatasetCacheError):
24 """Raised when a requested dataset is not present in the cache."""
27class DatasetIntegrityError(DatasetCacheError):
28 """Raised when cached dataset files are incomplete or inconsistent."""
31class HistoricalDataCache:
32 def __init__(self, root: str | Path = DEFAULT_CACHE_DIR):
33 self.root = Path(root)
35 def contains(self, dataset_id: str) -> bool:
36 return self._dataset_dir(dataset_id).is_dir()
38 def save(self, metadata: DatasetMetadata, bars: Mapping[str, Sequence[Bar]]) -> str:
39 logger.debug("Preparing dataset %s for cache", metadata.dataset_id)
41 normalized_bars = self._normalize_bars(metadata, bars)
42 bars_bytes = self._serialize_bars(normalized_bars)
43 bars_sha256 = hashlib.sha256(bars_bytes).hexdigest()
45 dataset_id = metadata.dataset_id
47 bar_count = sum(len(items) for items in normalized_bars.values())
48 logger.debug(
49 "Dataset %s contains %d bars; SHA-256=%s",
50 dataset_id,
51 bar_count,
52 bars_sha256,
53 )
55 dataset_dir = self._dataset_dir(dataset_id)
57 if dataset_dir.exists():
58 cached_metadata, cached_bars = self.load(dataset_id)
60 logger.debug(
61 "Dataset %s already exists in cache; validating existing files",
62 dataset_id,
63 )
65 if cached_metadata != metadata: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 raise DatasetIntegrityError(
67 f"Cached dataset {dataset_id!r} has metadata "
68 "that does not match its dataset ID.",
69 )
71 if self._serialize_bars(cached_bars) != bars_bytes:
72 raise DatasetIntegrityError(
73 f"Dataset {dataset_id!r} already exists with different bars.",
74 )
76 logger.debug("Existing cached dataset %s matches requested dataset", dataset_id)
77 return dataset_id
79 logger.debug("Writing dataset %s to cache directory %s", dataset_id, dataset_dir)
81 self.root.mkdir(parents=True, exist_ok=True)
83 temp_dir = Path(tempfile.mkdtemp(prefix=f".{dataset_id}.", dir=self.root))
85 try:
86 metadata_bytes = self._json_bytes(metadata.to_dict())
88 manifest = {
89 "cache_format_version": CACHE_FORMAT_VERSION,
90 "dataset_id": dataset_id,
91 "bar_count": sum(len(items) for items in normalized_bars.values()),
92 "bars_sha256": bars_sha256,
93 }
95 (temp_dir / "metadata.json").write_bytes(metadata_bytes)
96 (temp_dir / "bars.jsonl").write_bytes(bars_bytes)
97 (temp_dir / "manifest.json").write_bytes(self._json_bytes(manifest))
99 temp_dir.rename(dataset_dir)
101 logger.debug("Dataset %s cache write complete", dataset_id)
103 except Exception:
104 shutil.rmtree(temp_dir, ignore_errors=True)
105 raise
107 return dataset_id
109 def load(self, dataset_id: str) -> tuple[DatasetMetadata, dict[str, list[Bar]]]:
110 dataset_dir = self._dataset_dir(dataset_id)
112 if not dataset_dir.is_dir():
113 raise DatasetNotFoundError(
114 f"Dataset {dataset_id!r} was not found in cache {self.root}.",
115 )
117 logger.debug("Loading cached dataset %s from %s", dataset_id, dataset_dir)
119 metadata_path = dataset_dir / "metadata.json"
120 bars_path = dataset_dir / "bars.jsonl"
121 manifest_path = dataset_dir / "manifest.json"
123 for path in (metadata_path, bars_path, manifest_path):
124 if not path.is_file():
125 raise DatasetIntegrityError(
126 f"Cached dataset {dataset_id!r} is incomplete: missing {path.name}.",
127 )
129 metadata_data = self._read_json_object(metadata_path, dataset_id)
131 try:
132 metadata = DatasetMetadata.from_dict(metadata_data)
133 except (TypeError, ValueError) as exc:
134 raise DatasetIntegrityError(
135 f"Cached dataset {dataset_id!r} has invalid metadata: {exc}",
136 ) from exc
138 if metadata.dataset_id != dataset_id:
139 raise DatasetIntegrityError(
140 f"Cached dataset {dataset_id!r} has metadata for dataset {metadata.dataset_id!r}.",
141 )
143 manifest = self._read_json_object(manifest_path, dataset_id)
144 self._validate_manifest(manifest, dataset_id)
146 bars_bytes = bars_path.read_bytes()
147 actual_hash = hashlib.sha256(bars_bytes).hexdigest()
148 expected_hash = manifest["bars_sha256"]
150 logger.debug("Validating bars checksum for dataset %s", dataset_id)
152 if actual_hash != expected_hash:
153 raise DatasetIntegrityError(
154 f"Cached dataset {dataset_id!r} failed checksum validation for bars.jsonl.",
155 )
157 bars = self._deserialize_bars(bars_bytes, dataset_id)
158 normalized_bars = self._normalize_bars(metadata, bars)
159 actual_count = sum(len(items) for items in normalized_bars.values())
161 if actual_count != manifest["bar_count"]:
162 raise DatasetIntegrityError(
163 f"Cached dataset {dataset_id!r} has {actual_count} bars, "
164 f"but its manifest declares {manifest['bar_count']}.",
165 )
167 logger.debug("Cached dataset %s validated successfully: %d bars", dataset_id, actual_count)
169 return metadata, normalized_bars
171 def get_bars(self, dataset_id: str) -> dict[str, list[Bar]]:
172 _, bars = self.load(dataset_id)
173 return bars
175 def get_metadata(self, dataset_id: str) -> DatasetMetadata:
176 metadata, _ = self.load(dataset_id)
177 return metadata
179 def _dataset_dir(self, dataset_id: str) -> Path:
180 if not dataset_id or any(char not in "0123456789abcdef" for char in dataset_id):
181 raise ValueError("Dataset ID must be a non-empty lowercase hexadecimal string.")
183 return self.root / dataset_id
185 @staticmethod
186 def _json_bytes(data: object) -> bytes:
187 payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
188 return (payload + "\n").encode("utf-8")
190 @classmethod
191 def _serialize_bars(cls, bars: Mapping[str, Sequence[Bar]]) -> bytes:
192 records: list[bytes] = []
194 for symbol in sorted(bars):
195 for bar in bars[symbol]:
196 records.append(
197 cls._json_bytes(
198 {
199 "symbol": bar.symbol,
200 "timestamp": bar.timestamp.isoformat(),
201 "open": float(bar.open),
202 "high": float(bar.high),
203 "low": float(bar.low),
204 "close": float(bar.close),
205 "volume": float(bar.volume),
206 },
207 ),
208 )
210 return b"".join(records)
212 @staticmethod
213 def _deserialize_bars(payload: bytes, dataset_id: str) -> dict[str, list[Bar]]:
214 result: dict[str, list[Bar]] = {}
216 for line_number, raw_line in enumerate(payload.splitlines(), start=1):
217 if not raw_line.strip(): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 continue
220 try:
221 data = json.loads(raw_line)
223 if not isinstance(data, dict):
224 raise ValueError("bar record must be a JSON object")
226 bar = Bar(
227 symbol=data["symbol"],
228 timestamp=datetime.fromisoformat(data["timestamp"]),
229 open=float(data["open"]),
230 high=float(data["high"]),
231 low=float(data["low"]),
232 close=float(data["close"]),
233 volume=float(data["volume"]),
234 )
236 except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
237 raise DatasetIntegrityError(
238 f"Cached dataset {dataset_id!r} contains an "
239 f"invalid bar at line {line_number}: {exc}",
240 ) from exc
242 result.setdefault(bar.symbol, []).append(bar)
244 return result
246 @staticmethod
247 def _normalize_bars(
248 metadata: DatasetMetadata,
249 bars: Mapping[str, Sequence[Bar]],
250 ) -> dict[str, list[Bar]]:
251 expected_symbols = set(metadata.symbols)
252 actual_symbols = set(bars)
254 if actual_symbols != expected_symbols:
255 missing = sorted(expected_symbols - actual_symbols)
256 unexpected = sorted(actual_symbols - expected_symbols)
258 details: list[str] = []
260 if missing: 260 ↛ 263line 260 didn't jump to line 263 because the condition on line 260 was always true
261 details.append(f"missing symbols: {", ".join(missing)}")
263 if unexpected: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
264 details.append(f"unexpected symbols: {", ".join(unexpected)}")
266 raise DatasetIntegrityError(
267 f"Bars do not match dataset metadata ({"; ".join(details)}).",
268 )
270 result: dict[str, list[Bar]] = {}
272 for symbol in metadata.symbols:
273 items = list(bars[symbol])
275 for bar in items:
276 if bar.symbol != symbol:
277 raise DatasetIntegrityError(
278 f"Bar stored under {symbol!r} has symbol {bar.symbol!r}.",
279 )
281 if bar.timestamp.tzinfo is None:
282 raise DatasetIntegrityError(
283 f"Bar for {symbol!r} has a timezone-naive timestamp.",
284 )
286 result[symbol] = sorted(items, key=lambda bar: bar.timestamp)
288 return result
290 @staticmethod
291 def _read_json_object(path: Path, dataset_id: str) -> dict[str, object]:
292 try:
293 data = json.loads(path.read_text(encoding="utf-8"))
294 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
295 raise DatasetIntegrityError(
296 f"Cached dataset {dataset_id!r} has invalid {path.name}: {exc}",
297 ) from exc
299 if not isinstance(data, dict):
300 raise DatasetIntegrityError(
301 f"Cached dataset {dataset_id!r} has invalid {path.name}: expected a JSON object.",
302 )
304 return data
306 @staticmethod
307 def _validate_manifest(manifest: dict[str, object], dataset_id: str) -> None:
308 required = {"cache_format_version", "dataset_id", "bar_count", "bars_sha256"}
310 missing = sorted(required - manifest.keys())
312 if missing:
313 raise DatasetIntegrityError(
314 f"Cached dataset {dataset_id!r} has an invalid "
315 f"manifest: missing {", ".join(missing)}.",
316 )
318 if manifest["cache_format_version"] != CACHE_FORMAT_VERSION:
319 raise DatasetIntegrityError(
320 f"Cached dataset {dataset_id!r} uses unsupported cache "
321 f"format version {manifest["cache_format_version"]!r}.",
322 )
324 if manifest["dataset_id"] != dataset_id:
325 raise DatasetIntegrityError(
326 f"Cached dataset {dataset_id!r} has a manifest for {manifest["dataset_id"]!r}.",
327 )
329 if not isinstance(manifest["bar_count"], int) or isinstance(manifest["bar_count"], bool):
330 raise DatasetIntegrityError(
331 f"Cached dataset {dataset_id!r} has an invalid manifest bar_count.",
332 )
334 if not isinstance(manifest["bars_sha256"], str):
335 raise DatasetIntegrityError(
336 f"Cached dataset {dataset_id!r} has an invalid manifest bars_sha256.",
337 )