Coverage for adrs/data/cache.py: 17%
103 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 15:31 +0800
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 15:31 +0800
1import logging
2import polars as pl
4from pathlib import Path
5from datetime import (
6 date,
7 datetime,
8 timedelta,
9 timezone,
10)
12from adrs.types import Topic
13from adrs.data.datasource import Datasource
14from adrs.data.utils import make_filename, make_filepath, parse_date_from_filename
17logger = logging.getLogger(__name__)
20def _generate_date_range(start: datetime, end: datetime) -> list[date]:
21 dates, current = [], start.date()
22 while current <= end.date():
23 dates.append(current)
24 current += timedelta(days=1)
25 return dates
28class Cache:
29 def __init__(self, data_path: Path, format: str, override_existing: bool):
30 self.data_path: Path = data_path
31 self.fmt: str = format
32 self.override_existing: bool = override_existing
34 def init(
35 self, topic: Topic, start_time: datetime, end_time: datetime
36 ) -> tuple[datetime, datetime]:
37 filepath = self.data_path / make_filepath(topic, self.fmt)
38 parent_dir = filepath.parent
39 if not parent_dir.exists():
40 logger.debug("[%s] creating directory at %s", topic, parent_dir)
41 parent_dir.mkdir(parents=True, exist_ok=True)
43 # Floor start, ceil end to day boundaries
44 start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
45 end_midnight = end_time.replace(hour=0, minute=0, second=0, microsecond=0)
46 end_time = (
47 end_midnight + timedelta(days=1)
48 if end_time > end_midnight
49 else end_midnight
50 )
52 if self.override_existing:
53 logger.debug("[%s] override is set, skipping check for cache", topic)
54 return (start_time, end_time)
55 else:
56 filename_base = make_filename(topic)
57 downloaded_dates = [
58 d.date()
59 for f in parent_dir.iterdir()
60 if f.is_file() and filename_base in f.name
61 if (d := parse_date_from_filename(f.name)) is not None
62 ]
64 needed = _generate_date_range(start_time, end_time - timedelta(days=1))
65 filtered = [d for d in needed if d not in downloaded_dates]
67 if filtered:
68 download_start_time = datetime.combine(
69 min(filtered), datetime.min.time(), tzinfo=timezone.utc
70 )
71 download_end_time = datetime.combine(
72 max(filtered) + timedelta(days=1),
73 datetime.min.time(),
74 tzinfo=timezone.utc,
75 )
76 else:
77 download_start_time = end_time
78 download_end_time = end_time
79 return (download_start_time, download_end_time)
81 def _write_file(self, df: pl.DataFrame, path: Path, fmt: str) -> None:
82 match fmt:
83 case "parquet":
84 df.write_parquet(path)
85 case "json":
86 df.write_json(path)
87 case "csv":
88 df.write_csv(path)
89 case _:
90 raise ValueError(f"unsupported format: {fmt}")
92 def _read_file(self, path: Path, fmt: str) -> pl.DataFrame:
93 match fmt:
94 case "parquet":
95 return pl.read_parquet(path)
96 case "json":
97 return pl.read_json(path)
98 case "csv":
99 return pl.read_csv(path)
100 case _:
101 raise ValueError(f"unsupported format: {fmt}")
103 async def download(
104 self,
105 datasource: Datasource,
106 topic: Topic,
107 download_start_time: datetime,
108 download_end_time: datetime,
109 ) -> int:
110 if download_start_time >= download_end_time:
111 return 0
112 logger.info(
113 "[%s] downloading from %s to %s",
114 topic,
115 download_start_time,
116 download_end_time,
117 )
118 df = await datasource.query_paginated(
119 topic=topic,
120 start_time=download_start_time,
121 end_time=download_end_time,
122 flatten=True,
123 )
124 if df.is_empty():
125 return 0
127 df = df.with_columns(
128 pl.col("start_time").dt.strftime("%Y-%m-%d").alias("date"),
129 *[
130 pl.col(col).cast(pl.Float64, strict=False)
131 for col, dtype in df.schema.items()
132 if dtype != pl.Float64
133 and col not in ["start_time", "datetime", "date"]
134 # Multi-record-per-timestamp topics (full option chains, GEX
135 # snapshots) carry a nested List(Struct(...)) column -- not
136 # castable to Float64 (structurally, not just a value-level
137 # failure strict=False would relax). Leave nested columns
138 # as-is; only cast genuinely scalar columns.
139 and not isinstance(dtype, (pl.List, pl.Struct))
140 ],
141 )
143 for partition in df.partition_by("date", include_key=True):
144 date_str = partition["date"][0]
145 partition = partition.drop("date")
147 filepath = self.data_path / make_filepath(topic, self.fmt, suffix=date_str)
148 filepath.parent.mkdir(parents=True, exist_ok=True)
150 logger.debug(
151 "[%s] writing %d records to %s", topic, len(partition), filepath
152 )
153 self._write_file(partition, filepath, self.fmt)
155 return len(df)
157 async def read(
158 self,
159 topic: Topic,
160 start_time: datetime,
161 end_time: datetime,
162 ) -> pl.DataFrame:
163 parent_dir = self.data_path / make_filepath(topic, self.fmt).parent
164 if not parent_dir.exists():
165 raise FileNotFoundError(f"no files available at {parent_dir}")
167 filename_base = make_filename(topic)
169 entries = sorted(
170 [f for f in parent_dir.iterdir() if f.is_file()],
171 key=lambda f: parse_date_from_filename(f.name)
172 or datetime.min.replace(tzinfo=timezone.utc),
173 reverse=True,
174 )
176 frames: list[pl.DataFrame] = []
178 for f in entries:
179 date_dt = parse_date_from_filename(f.name)
180 if (
181 date_dt is None
182 or date_dt.date() < start_time.date()
183 or date_dt >= end_time
184 ):
185 continue
186 if filename_base not in f.name:
187 continue
189 frames.append(self._read_file(f, self.fmt))
191 if not frames:
192 raise FileNotFoundError("no files read in the output dir")
194 return pl.concat(frames, how="diagonal_relaxed").rechunk().sort("start_time")
196 async def fetch(
197 self,
198 datasource: Datasource,
199 topic: Topic,
200 start_time: datetime,
201 end_time: datetime,
202 ):
203 (download_start_time, download_end_time) = self.init(
204 topic=topic,
205 start_time=start_time,
206 end_time=end_time,
207 )
208 datapoints = await self.download(
209 datasource=datasource,
210 topic=topic,
211 download_start_time=download_start_time,
212 download_end_time=download_end_time,
213 )
214 logger.info("[%s] downloaded %d datapoints", topic, datapoints)
215 read_start = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
216 try:
217 return await self.read(
218 topic=topic, start_time=read_start, end_time=end_time
219 )
220 except FileNotFoundError:
221 return pl.DataFrame()