Coverage for adrs/data/datasource.py: 59%

203 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 15:31 +0800

1import httpx 

2import asyncio 

3import logging 

4import pandas as pd 

5import polars as pl 

6import clickhouse_connect 

7 

8from typing import cast 

9from abc import abstractmethod 

10from datetime import datetime, timedelta, timezone 

11 

12from adrs.types import Data, Topic, SortedDataList 

13from adrs.data.progress import inner_task 

14 

15logger = logging.getLogger(__name__) 

16 

17DEFAULT_API_URL = "https://api.datasource.cybotrade.rs" 

18 

19 

20def ms_to_dt(ms: int) -> datetime: 

21 return datetime.fromtimestamp(ms / 1000, tz=timezone.utc) 

22 

23 

24class Datasource: 

25 @abstractmethod 

26 async def query_paginated( 

27 self, 

28 topic: Topic | str, 

29 start_time: datetime | None = None, 

30 end_time: datetime | None = None, 

31 limit: int | None = None, 

32 flatten: bool = False, 

33 ) -> pl.DataFrame: 

34 """Paginate across multiple query() calls, returning all rows as one DataFrame. 

35 

36 Resolves missing args from topic.params before dispatching. 

37 - Range mode (start_time + end_time): pages forward until end_time. 

38 - Limit mode (limit only): pages backward from last closed candle. 

39 

40 Returns DataFrame with start_time as Datetime("ms", "UTC"). 

41 Raises ValueError if neither (start_time, end_time) nor limit can be resolved, 

42 or if topic has no interval. 

43 """ 

44 raise NotImplementedError() 

45 

46 

47class CybotradeDatasource(Datasource): 

48 def __init__( 

49 self, 

50 api_key: str, 

51 base_url: str = DEFAULT_API_URL, 

52 max_limit: int = 100_000, 

53 max_retries: int = 3, 

54 backoff_base: float = 2.0, 

55 ): 

56 self.api_key = api_key 

57 self.base_url = base_url.rstrip("/") 

58 self.max_limit = max_limit 

59 self.max_retries = max_retries 

60 self.backoff_base = backoff_base 

61 self._client = httpx.AsyncClient(timeout=120.0) 

62 

63 async def query( 

64 self, 

65 topic: Topic | str, 

66 start_time: datetime | None = None, 

67 end_time: datetime | None = None, 

68 limit: int | None = None, 

69 flatten: bool = False, 

70 ) -> list[Data]: 

71 if isinstance(topic, str): 

72 topic = Topic.from_str(topic) 

73 params: dict[str, str] = dict(topic.params) 

74 if start_time is not None: 

75 params["start_time"] = str(int(start_time.timestamp() * 1000)) 

76 if end_time is not None: 

77 params["end_time"] = str(int(end_time.timestamp() * 1000)) 

78 if limit is not None: 

79 params["limit"] = str(limit) 

80 if flatten: 

81 params["flatten"] = "true" 

82 

83 url = f"{self.base_url}/{topic.provider}/{topic.endpoint}" 

84 last_exc: Exception | None = None 

85 for attempt in range(self.max_retries): 

86 try: 

87 resp = await self._client.get( 

88 url, params=params, headers={"X-API-KEY": self.api_key} 

89 ) 

90 resp.raise_for_status() 

91 batch = resp.json()["data"] 

92 return cast( 

93 list[Data], 

94 [ 

95 {**item, "start_time": ms_to_dt(item["start_time"])} 

96 for item in batch 

97 ], 

98 ) 

99 except (httpx.TimeoutException, httpx.ConnectError) as e: 

100 last_exc = e 

101 wait = self.backoff_base**attempt 

102 logger.warning( 

103 "[%s] attempt %d/%d failed (%s), retrying in %.1fs", 

104 topic, 

105 attempt + 1, 

106 self.max_retries, 

107 type(e).__name__, 

108 wait, 

109 ) 

110 await asyncio.sleep(wait) 

111 raise last_exc # type: ignore[misc] 

112 

113 async def query_paginated( 

114 self, 

115 topic: Topic | str, 

116 start_time: datetime | None = None, 

117 end_time: datetime | None = None, 

118 limit: int | None = None, 

119 flatten: bool = False, 

120 ) -> pl.DataFrame: 

121 if isinstance(topic, str): 

122 topic = Topic.from_str(topic) 

123 

124 if start_time is None: 

125 if (st := topic.params.get("start_time")) is not None: 

126 start_time = ms_to_dt(int(st)) 

127 if end_time is None: 

128 if (st := topic.params.get("end_time")) is not None: 

129 end_time = ms_to_dt(int(st)) 

130 if limit is None: 

131 if (lim := topic.params.get("limit")) is not None: 

132 limit = int(lim) 

133 

134 if (start_time is None or end_time is None) and limit is None: 

135 raise ValueError( 

136 "either (start_time, end_time) or (limit) must be specified" 

137 ) 

138 interval = topic.interval() 

139 if interval is None: 

140 raise ValueError(f"{topic} does not include an interval") 

141 interval_ms = int(interval.total_seconds() * 1000) 

142 # Pages are kept as separate frames and stitched once at the end. Folding 

143 # each page into a running accumulator instead would re-materialise the 

144 # whole history per page — quadratic, and the reason a 1m topic used to 

145 # spike memory during a long backfill. 

146 pages: list[pl.DataFrame] = [] 

147 

148 if start_time and end_time: 

149 end_ms = (int(end_time.timestamp() * 1000) // interval_ms) * interval_ms 

150 inner_total: int | None = ( 

151 None 

152 if topic.is_block() 

153 else max( 

154 0, 

155 (end_ms - int(start_time.timestamp() * 1000)) // interval_ms, 

156 ) 

157 ) 

158 else: 

159 inner_total = None if topic.is_block() else limit 

160 

161 async with inner_task(str(topic), inner_total) as _bar: 

162 if start_time and end_time: 

163 # pyrefly: ignore [unbound-name] 

164 end_time = ms_to_dt(end_ms) 

165 

166 current_start = start_time 

167 total = (end_ms - int(start_time.timestamp() * 1000)) // interval_ms 

168 iter_n = 1 

169 

170 while current_start < end_time: 

171 current_limit = ( 

172 self.max_limit 

173 if topic.is_block() 

174 else min(self.max_limit, total) 

175 ) 

176 current_end_check = current_start + timedelta( 

177 milliseconds=current_limit * interval_ms 

178 ) 

179 

180 logger.debug( 

181 "[query %d] fetching %s (start_time: %s, limit: %d)", 

182 iter_n, 

183 topic, 

184 current_start, 

185 current_limit, 

186 ) 

187 resp = await self.query( 

188 topic, 

189 start_time=current_start, 

190 limit=current_limit, 

191 flatten=flatten, 

192 ) 

193 if len(resp) == 0: # stop pagination if no new data 

194 break 

195 

196 if "start_time" not in resp[0].keys(): 

197 raise ValueError( 

198 f"{type(self).__name__}.query() does not return 'start_time' in its records" 

199 ) 

200 if not isinstance(resp[0]["start_time"], datetime): 

201 raise TypeError( 

202 f"'start_time' must be tz-aware datetime, got {type(resp[0]['start_time'])}" 

203 ) 

204 num = len(resp) 

205 _bar.advance(num) 

206 logger.debug("[query %d] %s got %d datapoints", iter_n, topic, num) 

207 

208 page_end = max(r["start_time"] for r in resp) 

209 pages.append(pl.DataFrame(resp, infer_schema_length=None)) 

210 

211 if topic.is_block(): 

212 current_start = page_end + timedelta(seconds=1) 

213 else: 

214 total = max(0, total - num) 

215 current_start = page_end + interval 

216 if total == 0: 

217 break 

218 

219 iter_n += 1 

220 

221 if ( 

222 not topic.is_block() 

223 and current_end_check >= end_time 

224 and num < current_limit 

225 ): 

226 break 

227 elif topic.is_block() and num < (current_limit - 1): 

228 break 

229 

230 await asyncio.sleep(0.1) 

231 

232 if topic.is_block() and pages: 

233 block_window = pl.col("start_time").is_between( 

234 start_time, end_time, closed="left" 

235 ) 

236 pages = [page.filter(block_window) for page in pages] 

237 else: 

238 # paginate backwards from last_closed_time 

239 current_end = topic.last_closed_time(is_collect=False) 

240 remaining = limit 

241 iter_n = 1 

242 

243 while remaining > 0: 

244 current_limit = min(self.max_limit, remaining) 

245 logger.debug( 

246 "[query %d] fetching %s (end_time: %s, limit: %d)", 

247 iter_n, 

248 topic, 

249 current_end, 

250 current_limit, 

251 ) 

252 resp = await self.query( 

253 topic, 

254 end_time=current_end, 

255 limit=current_limit, 

256 flatten=flatten, 

257 ) 

258 num = len(resp) 

259 _bar.advance(num) 

260 logger.debug("[query %d] %s got %d datapoints", iter_n, topic, num) 

261 

262 page_start = min(r["start_time"] for r in resp) 

263 remaining -= num 

264 current_end = ( 

265 page_start - timedelta(seconds=1) 

266 if topic.is_block() 

267 else page_start - interval 

268 ) 

269 pages.append(pl.DataFrame(resp, infer_schema_length=None)) 

270 iter_n += 1 

271 

272 if num < current_limit: 

273 break 

274 await asyncio.sleep(0.1) 

275 

276 pages = [page for page in pages if page.height] 

277 if not pages: 

278 return pl.DataFrame() 

279 

280 # Pages are appended in fetch order, so keeping the last row for a 

281 # start_time preserves the old per-page merge rule: later wins. 

282 # maintain_order pins which duplicate that is rather than leaving it to 

283 # whichever thread finishes first. 

284 stitched = ( 

285 pl.concat(pages, how="diagonal_relaxed") 

286 .unique(subset="start_time", keep="last", maintain_order=True) 

287 .sort("start_time") 

288 ) 

289 # Round-trip through the store so start_time lands as Datetime("ms", "UTC"). 

290 return SortedDataList.from_df(stitched).to_df() 

291 

292 

293class ClickhouseDatasource(Datasource): 

294 def __init__(self, **kwargs): 

295 self.connect_params = kwargs 

296 self.ch = None 

297 

298 async def _client(self) -> clickhouse_connect.driver.AsyncClient: 

299 if self.ch is None: 

300 self.ch = await clickhouse_connect.get_async_client(**self.connect_params) 

301 return self.ch 

302 

303 def _make_query_params_filter(self, topic: Topic): 

304 if topic.provider == "bybit" and topic.endpoint == "double-win": 

305 return f"query_params LIKE '%interval={topic.params.get('interval')}&product={topic.params.get('currency')}%'" 

306 else: 

307 return f"query_params = '{topic.query_params_str()}'" 

308 

309 def _make_query(self, topic: Topic, start_time: datetime, end_time: datetime): 

310 return f""" 

311 SELECT timestamp AS start_time, data FROM `{topic.provider}`.`{topic.endpoint}` FINAL 

312 WHERE {self._make_query_params_filter(topic)} 

313 AND timestamp >= '{start_time.strftime("%Y-%m-%d %H:%M:%S")}' 

314 AND timestamp < '{end_time.strftime("%Y-%m-%d %H:%M:%S")}' 

315 ORDER BY timestamp ASC 

316 """ 

317 

318 # Empirically-set chunk size (not a config knob elsewhere): a single 

319 # unpaginated multi-year query against a multi-record-per-timestamp 

320 # topic (full option chains, GEX snapshots -- ~800 nested structs per 

321 # row) overloaded clickhouse-connect's streaming parser 

322 # (StreamFailureError: "unrecognized data found in stream", ~5min in, 

323 # against a 7-year range). A single month (~744 rows * ~800 structs) 

324 # completed reliably in the same test. Chunking unconditionally (not 

325 # just for detected multi-record topics) keeps this simple and safe for 

326 # any topic, at the cost of a few extra round-trips for small 

327 # single-record ranges that were already fine unchunked. 

328 _MAX_CHUNK_DAYS = 31 

329 

330 async def query_paginated( 

331 self, 

332 topic: Topic | str, 

333 start_time: datetime | None = None, 

334 end_time: datetime | None = None, 

335 limit: int | None = None, 

336 flatten: bool = False, 

337 ) -> pl.DataFrame: 

338 if isinstance(topic, str): 

339 topic = Topic.from_str(topic) 

340 if start_time is None or end_time is None: 

341 raise ValueError("start_time and end_time must be provided") 

342 

343 if end_time - start_time <= timedelta(days=self._MAX_CHUNK_DAYS): 

344 return await self._query_one_range(topic, start_time, end_time) 

345 

346 frames = [] 

347 cur = start_time 

348 while cur < end_time: 

349 nxt = min(cur + timedelta(days=self._MAX_CHUNK_DAYS), end_time) 

350 chunk = await self._query_one_range(topic, cur, nxt) 

351 if len(chunk): 

352 frames.append(chunk) 

353 cur = nxt 

354 return pl.concat(frames, how="vertical") if frames else pl.DataFrame() 

355 

356 async def _query_one_range( 

357 self, topic: Topic, start_time: datetime, end_time: datetime 

358 ) -> pl.DataFrame: 

359 ch = await self._client() 

360 

361 # clickhouse-connect for pyarrow doesn't support JSON column yet, 

362 # see: https://github.com/ClickHouse/clickhouse-connect/issues/398 

363 # df = await ch.query_df_arrow( 

364 # self._make_query(topic, start_time, end_time), dataframe_library="polars" 

365 # ) 

366 

367 pandas_df = await ch.query_df(self._make_query(topic, start_time, end_time)) 

368 if pandas_df.empty: 

369 return pl.DataFrame() 

370 

371 # Multi-record-per-timestamp topics (full option chains, GEX 

372 # snapshots): "data" is a LIST of per-instrument dicts, not one 

373 # flat dict. json_normalize on a list-per-cell Series indexes 

374 # positionally ("0".."N" columns) -- meaningless here since column 

375 # identity isn't stable across snapshots (chain composition 

376 # changes every hour), and it drops the "data" column name that 

377 # DataColumn(src="data", ...) needs downstream. Keep the list 

378 # intact; normalize numeric fields to float so polars can infer 

379 # one consistent Struct schema across all records instead of 

380 # choking on mixed int/float typing for the same field across 

381 # instruments. 

382 # 

383 # ClickHouse's raw storage preserves the source API's full 

384 # per-timestamp envelope, which for multi-record topics is itself 

385 # `{"data": [...]}` -- one extra wrapper level versus what the 

386 # cybotrade REST proxy client hands back (it unwraps `resp.json() 

387 # ["data"]` itself before this code ever sees it). Verified directly 

388 # via a raw SQL query against the real flow.`options/gamma-exposure` 

389 # table: cells are dicts of the form {"data": [<840 instrument 

390 # dicts>]}, not the list directly. Unwrap that one level, if present, 

391 # before checking for the list shape -- a no-op for any topic where 

392 # "data" is already the list itself. 

393 def _unwrap(v): 

394 if ( 

395 isinstance(v, dict) 

396 and set(v.keys()) == {"data"} 

397 and isinstance(v["data"], list) 

398 ): 

399 return v["data"] 

400 return v 

401 

402 first_non_null = _unwrap( 

403 next((v for v in pandas_df["data"] if v is not None), None) 

404 ) 

405 is_multi_record = isinstance(first_non_null, list) 

406 

407 if is_multi_record: 

408 

409 def _normalize_record(rec: dict) -> dict: 

410 # Two independent sources of mixed typing for the SAME 

411 # struct field across different records, both verified 

412 # directly against real GEX data, not assumed: 

413 # 1. isinstance(v, (int, float)) doesn't catch numpy scalar 

414 # types (np.int64, np.float64) that clickhouse-connect's 

415 # driver returns for numeric JSON fields. 

416 # 2. The underlying JSON itself is inconsistently typed -- 

417 # the SAME field (e.g. dealerTotalInventory) arrives as a 

418 # JSON string in some records and a JSON number in 

419 # others. A blanket "leave strings alone" rule preserves 

420 # that inconsistency instead of fixing it. 

421 # Try a numeric cast on everything except None/bool; genuine 

422 # text fields (currency, exchange, putCall, 

423 # instrumentNormalized) simply fail the cast and keep their 

424 # original string value. 

425 out = {} 

426 for k, v in rec.items(): 

427 if v is None or isinstance(v, bool): 

428 out[k] = v 

429 continue 

430 try: 

431 out[k] = float(v) 

432 except (TypeError, ValueError): 

433 # Some numeric fields serialize missing values as 

434 # the literal JSON string "null" (verified: real 

435 # data, not a true JSON null) instead of an actual 

436 # null -- treat those as None rather than leaving a 

437 # stray string in an otherwise-numeric field. 

438 if isinstance(v, str) and v.strip().lower() in ( 

439 "null", 

440 "none", 

441 "nan", 

442 ): 

443 out[k] = None 

444 else: 

445 out[k] = v 

446 return out 

447 

448 normalized = pandas_df["data"].map( 

449 lambda cell: [_normalize_record(r) for r in _unwrap(cell)] 

450 if cell is not None 

451 else cell 

452 ) 

453 pandas_df = pandas_df.drop(columns=["data"]) 

454 else: 

455 pandas_df = pandas_df.drop(columns=["data"]).join( 

456 # pyrefly: ignore [bad-argument-type] 

457 pd.json_normalize(pandas_df["data"]) 

458 ) 

459 

460 for col in pandas_df.columns: 

461 # Fix dynamic 'object' columns containing mixed PyArrow-unfriendly types 

462 if pandas_df[col].dtype == "object": 

463 # Check if it's primarily numeric despite being labeled an 'object' 

464 converted_numeric = pd.to_numeric(pandas_df[col], errors="coerce") 

465 if not converted_numeric.isna().all(): 

466 pandas_df[col] = converted_numeric 

467 else: 

468 # If it's mixed text/strings/None, force everything to string safely 

469 pandas_df[col] = ( 

470 pandas_df[col] 

471 .astype(str) 

472 .replace("None", None) 

473 .replace("nan", None) 

474 ) 

475 

476 df = pl.from_pandas(pandas_df) 

477 if is_multi_record: 

478 # Build "data" via pl.Series directly from the normalized 

479 # Python list rather than pl.from_pandas' generic object-column 

480 # handling -- polars' own type inference over a list of dicts 

481 # reliably produces List(Struct(...)) once values are 

482 # type-consistent (verified against real GEX data); pandas' 

483 # pyarrow-conversion path for nested Python objects is not. 

484 df = df.with_columns(pl.Series("data", normalized.tolist())) 

485 

486 # Enforce the Datasource contract (start_time as Datetime("ms", 

487 # "UTC")) the same way CybotradeDatasource does via SortedDataList. 

488 # ClickHouse's plain `DateTime` column type carries no explicit tz, 

489 # so clickhouse-connect -> pandas -> pl.from_pandas produces a 

490 # tz-naive start_time here -- without this, cache.py's pl.concat 

491 # of old (Cybotrade-sourced, tz-aware) and new (Clickhouse-sourced, 

492 # tz-naive) cached shards for the same series raises SchemaError. 

493 df = df.with_columns( 

494 pl.col("start_time") 

495 .dt.replace_time_zone(time_zone="UTC") 

496 .dt.cast_time_unit(time_unit="ms") 

497 ) 

498 

499 return df