quantpad-data 0.8.1__tar.gz → 0.8.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/PKG-INFO +1 -1
  2. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/pyproject.toml +1 -1
  3. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/__init__.py +2 -1
  4. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/cache.py +21 -3
  5. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/client.py +78 -6
  6. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/errors.py +33 -1
  7. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/memory.py +29 -3
  8. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/v2.py +91 -17
  9. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/conftest.py +11 -0
  10. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_client.py +122 -0
  11. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2.py +152 -5
  12. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2_cache.py +77 -1
  13. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/.gitignore +0 -0
  14. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/README.md +0 -0
  15. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/lean.py +0 -0
  16. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/py.typed +0 -0
  17. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/rth.py +0 -0
  18. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_lean.py +0 -0
  19. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_message_encoding.py +0 -0
  20. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_packaging.py +0 -0
  21. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_refusal_hint.py +0 -0
  22. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_rth.py +0 -0
  23. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.8.1
3
+ Version: 0.8.4
4
4
  Summary: Official Python client for the QuantPad market-data API
5
5
  Project-URL: Documentation, https://api.quantpad.ai/external/docs
6
6
  Author: QuantPad
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.8.1"
7
+ version = "0.8.4"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -11,6 +11,7 @@ import pyarrow as pa
11
11
  from .client import Client, ROLL_ADJUST_MODES, TICK_SCHEMAS, USER_AGENT, VERSION
12
12
  from .errors import (
13
13
  AuthenticationError,
14
+ IncompleteResponseError,
14
15
  NotFoundError,
15
16
  PermissionDeniedError,
16
17
  QuantPadDataError,
@@ -213,7 +214,7 @@ __all__ = [
213
214
  "Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
214
215
  "PermissionDeniedError", "RateLimitError", "QuotaExceededError",
215
216
  "NotFoundError", "V2NotAvailableError", "ValidationError", "UpstreamError",
216
- "ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
217
+ "IncompleteResponseError", "ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
217
218
  "ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
218
219
  "get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
219
220
  "get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
@@ -349,6 +349,12 @@ def _columns_tag(columns: Sequence[str] | None) -> str:
349
349
  #: would serve the wrong rows.
350
350
  _DELIVERY_ONLY = frozenset({"compression", "max_decoded_bytes"})
351
351
 
352
+ #: Changed whenever the service starts answering an unchanged body with
353
+ #: different rows, so every entry cached under the old answer misses once.
354
+ #: 2: until 2026-10, a window ending mid-day was answered without its last
355
+ #: day, and that short answer was cached here as the window's answer.
356
+ _RANGE_KEY_EPOCH = 2
357
+
352
358
 
353
359
  def _request_key(body: dict) -> str:
354
360
  """A stable key for the answer a request body asks for.
@@ -357,7 +363,9 @@ def _request_key(body: dict) -> str:
357
363
  which is what stops it drifting as ``get_range`` grows arguments.
358
364
  """
359
365
  material = {k: v for k, v in sorted(body.items()) if k not in _DELIVERY_ONLY}
360
- return _hash_key([json.dumps(material, sort_keys=True, default=str)])
366
+ return _hash_key(
367
+ [str(_RANGE_KEY_EPOCH), json.dumps(material, sort_keys=True, default=str)]
368
+ )
361
369
 
362
370
 
363
371
  def range_path(body: dict) -> Path:
@@ -403,13 +411,23 @@ def read_meta(path: Path) -> dict | None:
403
411
  return _read_meta(path)
404
412
 
405
413
 
406
- def get_frame(path: Path) -> pd.DataFrame | None:
414
+ def get_frame(path: Path, *, expect: dict | None = None) -> pd.DataFrame | None:
407
415
  """Read a cached frame, or None on miss.
408
416
 
409
417
  A corrupt or half-written entry is dropped and reported as a miss, so a
410
418
  damaged cache degrades to the network rather than failing the request.
419
+
420
+ ``expect`` names sidecar fields the entry must carry exactly, and a
421
+ mismatch is a miss. This is how a partition the service has since
422
+ re-fetched stops being served from disk: the manifest's ``version``
423
+ moves when upstream corrects a day, and a settled day is otherwise
424
+ never asked about again. An entry written before versions existed
425
+ carries none, so it misses once against a server that sends one.
411
426
  """
412
- if not enabled() or _read_meta(path) is None:
427
+ meta = _read_meta(path) if enabled() else None
428
+ if meta is None:
429
+ return None
430
+ if any(meta.get(field) != value for field, value in (expect or {}).items()):
413
431
  return None
414
432
  try:
415
433
  frame = pd.read_parquet(path)
@@ -10,11 +10,14 @@ from datetime import date
10
10
  from typing import Iterator, Sequence
11
11
 
12
12
  import pandas as pd
13
+ import pyarrow as pa
13
14
  import pyarrow.ipc as ipc
14
15
  import requests
16
+ import urllib3
15
17
 
16
18
  from .errors import (
17
19
  AuthenticationError,
20
+ IncompleteResponseError,
18
21
  NotFoundError,
19
22
  PermissionDeniedError,
20
23
  QuantPadDataError,
@@ -84,7 +87,7 @@ V2_NOT_AVAILABLE = (
84
87
  "QuantPad support about /v2 access."
85
88
  )
86
89
 
87
- VERSION = "0.8.1"
90
+ VERSION = "0.8.4"
88
91
  USER_AGENT = f"quantpad-data-python/{VERSION}"
89
92
  TICK_SCHEMAS = (
90
93
  "trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
@@ -142,6 +145,50 @@ BARS_TIMEOUT = (30, 1800)
142
145
  #: stall detector and comes down to a couple of minutes.
143
146
  V2_DATA_TIMEOUT = (10, 1800)
144
147
 
148
+ #: Backoff between retries: exponential, with jitter, so that the clients
149
+ #: of one outage do not come back in lockstep and knock it over again.
150
+ #:
151
+ #: Was 0.5s, 1s, 2s plus at most 0.25s, with the server's ``Retry-After``
152
+ #: ignored on every 5xx. During the 2026-10-01/02 API-key outage the
153
+ #: service answered every request "503, Retry-After: 5"; this client
154
+ #: retried each call three more times inside four seconds, and one key
155
+ #: kept that up for sixteen hours.
156
+ RETRY_BACKOFF_BASE = 1.0
157
+ RETRY_BACKOFF_MAX = 30.0
158
+
159
+ #: The longest ``Retry-After`` honoured, from any status. Past this the
160
+ #: client would rather fail and let its caller decide than sleep silently:
161
+ #: a daily-quota 429 can say "come back in twenty hours".
162
+ RETRY_AFTER_MAX = 60.0
163
+
164
+
165
+ #: What reading a stream raises when the connection ends early: urllib3's
166
+ #: incomplete-chunk error from the socket, requests' wrapper of the same,
167
+ #: and what pyarrow raises when a message is cut short (ArrowInvalid) or
168
+ #: its read fails (OSError).
169
+ _BROKEN_STREAM = (
170
+ urllib3.exceptions.HTTPError,
171
+ requests.exceptions.RequestException,
172
+ pa.ArrowInvalid,
173
+ OSError,
174
+ )
175
+
176
+
177
+ def _retry_delay(attempt: int, retry_after: float | None) -> float:
178
+ """Seconds to wait before retry number ``attempt + 1``.
179
+
180
+ Exponential with equal jitter -- half the step fixed, half random --
181
+ and never sooner than the server asked, though no later than
182
+ ``RETRY_AFTER_MAX``. The server's figure is jittered too, because
183
+ every client of an outage receives the same one.
184
+ """
185
+ step = min(RETRY_BACKOFF_MAX, RETRY_BACKOFF_BASE * (2**attempt))
186
+ delay = step / 2 + random.uniform(0, step / 2)
187
+ if retry_after is not None and retry_after > 0:
188
+ asked = min(float(retry_after), RETRY_AFTER_MAX)
189
+ delay = max(delay, asked + random.uniform(0, min(1.0, asked / 4)))
190
+ return delay
191
+
145
192
 
146
193
  def _prose_with_hint(error: dict) -> str:
147
194
  """The sentence to raise from a structured refusal.
@@ -269,7 +316,7 @@ class Client:
269
316
  cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
270
317
  raise cls(message, retry_after, status)
271
318
  if status >= 500:
272
- raise UpstreamError(message, status)
319
+ raise UpstreamError(message, status, retry_after=retry_after)
273
320
  raise QuantPadDataError(message, status, detail)
274
321
 
275
322
  def _request(self, method: str, path: str, **kwargs) -> requests.Response:
@@ -322,9 +369,8 @@ class Client:
322
369
  last_error = exc
323
370
  if attempt >= self.max_retries:
324
371
  raise
325
- delay = None
326
- base_delay = delay if delay is not None else min(8.0, 0.5 * (2**attempt))
327
- time.sleep(max(0.0, base_delay) + random.uniform(0, 0.25))
372
+ delay = getattr(exc, "retry_after", None)
373
+ time.sleep(_retry_delay(attempt, delay))
328
374
  raise QuantPadDataError(str(last_error or "Request failed"))
329
375
 
330
376
  @staticmethod
@@ -417,8 +463,34 @@ class Client:
417
463
  response.raw.decode_content = True
418
464
  buffered: list[pd.DataFrame] = []
419
465
  buffered_rows = 0
466
+ received = 0
467
+
468
+ def broken(exc: BaseException) -> IncompleteResponseError:
469
+ return IncompleteResponseError(
470
+ f"The /v1/ticks stream for {params['symbol']} {schema} ended "
471
+ f"before the service finished sending it ({received:,} rows "
472
+ "had arrived). Nothing from this call is complete; retry the "
473
+ f"request. ({type(exc).__name__}: {exc})",
474
+ rows_received=received,
475
+ )
476
+
420
477
  try:
421
- for batch in ipc.open_stream(response.raw):
478
+ # Read errors are told apart from a clean end because the
479
+ # service now ends a failed stream by closing the connection
480
+ # early -- its 200 has already gone out -- and the raw urllib3
481
+ # ProtocolError that surfaces says nothing about what it means.
482
+ try:
483
+ batches = iter(ipc.open_stream(response.raw))
484
+ except _BROKEN_STREAM as exc:
485
+ raise broken(exc) from exc
486
+ while True:
487
+ try:
488
+ batch = next(batches)
489
+ except StopIteration:
490
+ break
491
+ except _BROKEN_STREAM as exc:
492
+ raise broken(exc) from exc
493
+ received += batch.num_rows
422
494
  if not batch.num_rows:
423
495
  continue
424
496
  buffered.append(batch.to_pandas(split_blocks=True, self_destruct=True))
@@ -134,7 +134,39 @@ class ValidationError(ValueError, QuantPadDataError):
134
134
 
135
135
 
136
136
  class UpstreamError(QuantPadDataError):
137
- pass
137
+ """A 5xx from the service.
138
+
139
+ ``retry_after`` is the service's ``Retry-After`` in seconds when it sent
140
+ one: a 503 that says when to come back is a 503 worth believing, and the
141
+ client waits at least that long before retrying it.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ message: str,
147
+ status_code: int | None = None,
148
+ detail: dict | None = None,
149
+ *,
150
+ retry_after: float | None = None,
151
+ ):
152
+ super().__init__(message, status_code, detail)
153
+ self.retry_after = retry_after
154
+
155
+
156
+ class IncompleteResponseError(UpstreamError):
157
+ """A streamed response ended before the service finished sending it.
158
+
159
+ The service now ends a tick stream this way when it fails partway --
160
+ the status was already 200, so the connection is the only signal
161
+ left. Nothing this call returned can be taken as complete; retry the
162
+ request. A subclass of :class:`UpstreamError`, so existing ``except
163
+ UpstreamError`` handlers keep catching it. ``rows_received`` counts
164
+ the rows that had arrived when the stream broke.
165
+ """
166
+
167
+ def __init__(self, message: str, rows_received: int = 0):
168
+ super().__init__(message)
169
+ self.rows_received = rows_received
138
170
 
139
171
 
140
172
  class QuantPadDataNotReady(QuantPadDataError):
@@ -42,6 +42,29 @@ import os
42
42
  #: returns to killing the process with no message.
43
43
  PEAK_PER_PRICED_BYTE = 6.0
44
44
 
45
+ #: Measured factors for the schemas where the 6x worst case refused what
46
+ #: fitted -- in 14 days of production sandboxes, 31 of 74 refusals were ~1 GB
47
+ #: or less, mostly single days of 80-byte quote records. Measured 2026-10-28
48
+ #: (peak RSS over priced bytes): mbp-10 2.37x and 2.57x; mbp-1 3.17x-3.19x on
49
+ #: full days near the budget and 4.32x on a 63 MB one; trades 4.73x, so trades
50
+ #: keeps the default. The other quote schemas share mbp-1's record shape.
51
+ #: Kept identical to the sandbox client's ``_PEAK_BY_SCHEMA``.
52
+ PEAK_BY_SCHEMA = {
53
+ "mbp-10": 3.0,
54
+ "mbp-1": 4.5,
55
+ "tbbo": 4.5,
56
+ "bbo-1s": 4.5,
57
+ "bbo-1m": 4.5,
58
+ "cmbp-1": 4.5,
59
+ "tcbbo": 4.5,
60
+ "cbbo-1s": 4.5,
61
+ "cbbo-1m": 4.5,
62
+ }
63
+
64
+
65
+ def peak_per_priced_byte(schema: str | None) -> float:
66
+ return PEAK_BY_SCHEMA.get(schema, PEAK_PER_PRICED_BYTE)
67
+
45
68
  #: cgroup v1 spells "no limit" as a huge number rather than a word, and the
46
69
  #: exact sentinel varies with page size, so anything absurd is read as absent.
47
70
  _NO_LIMIT = 1 << 53
@@ -172,7 +195,7 @@ def available_bytes() -> int | None:
172
195
  return min(candidates) if candidates else None
173
196
 
174
197
 
175
- def declared_budget(mode: str) -> int | None:
198
+ def declared_budget(mode: str, schema: str | None = None) -> int | None:
176
199
  """The figure to send as ``max_decoded_bytes``, in the service's units.
177
200
 
178
201
  TWO NUMBERS, BECAUSE THE COST OF BEING WRONG DIFFERS BY MODE.
@@ -186,9 +209,12 @@ def declared_budget(mode: str) -> int | None:
186
209
  and there is nowhere to degrade to, so a refusal is a hard failure. It
187
210
  therefore declares free memory UNDIVIDED, which refuses only what cannot
188
211
  possibly fit: peak is larger than the priced figure in every case ever
189
- measured (2.49x at the lowest), so a request priced above all available
212
+ measured (2.37x at the lowest), so a request priced above all available
190
213
  memory is a certain MemoryError rather than a risk of one. Nothing that
191
214
  works today starts failing.
215
+
216
+ Under ``"auto"`` the divisor is the schema's measured factor where one
217
+ exists (:data:`PEAK_BY_SCHEMA`), and the worst case otherwise.
192
218
  """
193
219
  override = os.getenv(BUDGET_ENV, "").strip()
194
220
  if override:
@@ -203,4 +229,4 @@ def declared_budget(mode: str) -> int | None:
203
229
  return None
204
230
  if mode == "interactive":
205
231
  return free
206
- return int(free / PEAK_PER_PRICED_BYTE)
232
+ return int(free / peak_per_priced_byte(schema))
@@ -23,10 +23,13 @@ request.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import collections
27
+ import concurrent.futures
26
28
  import io
27
29
  import json
28
30
  import os
29
31
  import tempfile
32
+ import threading
30
33
  import warnings
31
34
  from datetime import date, timedelta
32
35
  from typing import Any, Iterator, Sequence
@@ -83,6 +86,39 @@ STORAGE_SPOOL_BYTES = 32 * 1024 * 1024
83
86
  #: all -- it has no gaps, so it stays the single manifest it always was.
84
87
  HYDRATE_CHUNK_DAYS = 5
85
88
 
89
+ #: How many of those runs hydrate at once. One at a time, each waited for
90
+ #: the one before it, so a cold window cost the SUM of its fetches: measured
91
+ #: 2026-10-28 on production, a cold 12-day GF.v.0 window took 99.0s one at a
92
+ #: time and 33.6s four at a time, with identical rows. Hydration returns a
93
+ #: manifest, not rows, so running ahead costs this process nothing; frames
94
+ #: are still read and yielded one at a time, in order. Kept identical to
95
+ #: the sandbox client's ``V2_HYDRATE_PARALLEL``.
96
+ HYDRATE_PARALLEL = 4
97
+
98
+
99
+ def _in_background(fn, *args) -> "concurrent.futures.Future":
100
+ """Run ``fn(*args)`` on a DAEMON thread and return its future.
101
+
102
+ Not a ``ThreadPoolExecutor``: its workers are joined at interpreter
103
+ exit, and a hydration already in flight cannot be cancelled, so a
104
+ script that stopped iterating a cold window early would hang on exit
105
+ until those requests returned -- up to the data timeout. A daemon
106
+ thread is abandoned at exit instead. Concurrency stays bounded by the
107
+ caller, which keeps at most ``HYDRATE_PARALLEL`` unconsumed.
108
+ """
109
+ future: concurrent.futures.Future = concurrent.futures.Future()
110
+
111
+ def run() -> None:
112
+ if not future.set_running_or_notify_cancel():
113
+ return
114
+ try:
115
+ future.set_result(fn(*args))
116
+ except BaseException as exc: # noqa: BLE001 - handed to the consumer
117
+ future.set_exception(exc)
118
+
119
+ threading.Thread(target=run, name="qpd-hydrate", daemon=True).start()
120
+ return future
121
+
86
122
 
87
123
  def _symbols(value: str | Sequence[str]) -> list[str]:
88
124
  return [value] if isinstance(value, str) else [str(v) for v in value]
@@ -341,7 +377,7 @@ class Timeseries(_Namespace):
341
377
  # service too old to know the field, which ignores unknown keys.
342
378
  budget = (
343
379
  max_decoded_bytes if max_decoded_bytes is not None
344
- else memory.declared_budget(mode)
380
+ else memory.declared_budget(mode, schema)
345
381
  )
346
382
  if budget is not None:
347
383
  # A MEASURED ZERO is not the same as "could not measure", and
@@ -643,20 +679,43 @@ class Timeseries(_Namespace):
643
679
  # One cursor over the union of both sets, so a fetched day lands
644
680
  # between the cached days that surround it.
645
681
  fetched: dict[str, list[tuple[dict, dict]]] = {}
646
- pending = list(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
647
- for day in sorted(set(ready) | missing):
648
- while pending and pending[0][0] <= day:
649
- start, end = pending.pop(0)
650
- for payload in self._manifests(
651
- {**manifest_body, "start": start, "end": end}
652
- ):
653
- for item in payload["files"]:
654
- fetched.setdefault(item["day"], []).append((payload, item))
655
- yield from fresh(ready.get(day, ()))
656
- # A missing day that came back with nothing -- a holiday, or
657
- # a genuinely quiet session -- simply has no entry here, and
658
- # is skipped exactly as an ``empty`` day always was.
659
- yield from fresh(fetched.pop(day, ()))
682
+ pending = collections.deque(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
683
+
684
+ def hydrate(start: str, end: str) -> list[dict]:
685
+ return list(self._manifests({**manifest_body, "start": start, "end": end}))
686
+
687
+ # Runs hydrate up to HYDRATE_PARALLEL at a time, ahead of the
688
+ # cursor, and are CONSUMED in order: a run's files join ``fetched``
689
+ # only when the cursor reaches its first day, exactly as when they
690
+ # were fetched there, so the order frames come out in is unchanged.
691
+ inflight: collections.deque = collections.deque()
692
+
693
+ def top_up() -> None:
694
+ while pending and len(inflight) < HYDRATE_PARALLEL:
695
+ start, end = pending.popleft()
696
+ inflight.append((start, _in_background(hydrate, start, end)))
697
+
698
+ try:
699
+ top_up()
700
+ for day in sorted(set(ready) | missing):
701
+ while inflight and inflight[0][0] <= day:
702
+ _, future = inflight.popleft()
703
+ # Re-raises the run's own error here, at the day it
704
+ # covers, which is where the sequential loop raised it.
705
+ for payload in future.result():
706
+ for item in payload["files"]:
707
+ fetched.setdefault(item["day"], []).append((payload, item))
708
+ top_up()
709
+ yield from fresh(ready.get(day, ()))
710
+ # A missing day that came back with nothing -- a holiday, or
711
+ # a genuinely quiet session -- simply has no entry here, and
712
+ # is skipped exactly as an ``empty`` day always was.
713
+ yield from fresh(fetched.pop(day, ()))
714
+ finally:
715
+ # A caller that stops iterating early: runs already started finish
716
+ # on their daemon threads and are dropped; none is started after.
717
+ for _, future in inflight:
718
+ future.cancel()
660
719
 
661
720
  # Anything a fetch returned for a day the survey did not list.
662
721
  # It should be empty, and it is drained rather than asserted
@@ -979,6 +1038,14 @@ class Options(_Namespace):
979
1038
  instrument_class: str | None = None,
980
1039
  limit: int | None = None,
981
1040
  ) -> pd.DataFrame:
1041
+ """Outright calls and puts for ``underlier`` as of ``date``.
1042
+
1043
+ ``underlier`` is the parent (``SPY.OPT``, ``NQ.OPT``); a futures
1044
+ option handle such as ``CL.FUT.OPT`` is accepted and sent as the
1045
+ parent upstream knows (``LO.OPT``). Spreads are left out unless
1046
+ their class is asked for (``instrument_class="T"``), and
1047
+ ``expiration`` is a ``YYYY-MM-DD`` UTC date.
1048
+ """
982
1049
  params = {
983
1050
  "dataset": dataset,
984
1051
  "underlier": underlier,
@@ -1330,7 +1397,13 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1330
1397
  hostile ``QPD_CACHE_DIR`` can contain, and the only other way it could
1331
1398
  fail is a manifest item missing ``day`` or ``instrument_id``, which the
1332
1399
  caller subscripts for its own labels two lines later regardless.
1400
+
1401
+ A settled day is not immutable: the service re-fetches a day its
1402
+ vendor regenerates, and says so by changing the item's ``version``. A
1403
+ hit therefore has to be the version the manifest describes, or it is
1404
+ the uncorrected copy served from disk for as long as the disk keeps it.
1333
1405
  """
1406
+ expect = {"version": item.get("version")}
1334
1407
  path = None
1335
1408
  if cache.enabled() and cache.day_settled(item.get("day")):
1336
1409
  path = cache.day_path(
@@ -1343,14 +1416,15 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1343
1416
  )
1344
1417
 
1345
1418
  if path is not None:
1346
- hit = cache.get_frame(path)
1419
+ hit = cache.get_frame(path, expect=expect)
1347
1420
  if hit is not None:
1348
1421
  return hit
1349
1422
 
1350
1423
  frame = _read_parquet_url(item["url"], columns)
1351
1424
  if path is not None:
1352
1425
  cache.put_frame(
1353
- path, frame, {"day": item["day"], "dataset": body["dataset"]}
1426
+ path, frame,
1427
+ {"day": item["day"], "dataset": body["dataset"], **expect},
1354
1428
  )
1355
1429
  return frame
1356
1430
 
@@ -24,3 +24,14 @@ def isolated_cache(tmp_path, monkeypatch):
24
24
  monkeypatch.delenv("QPD_CACHE", raising=False)
25
25
  monkeypatch.delenv("QPD_CACHE_MAX_BYTES", raising=False)
26
26
  return tmp_path / "qpd-cache"
27
+
28
+
29
+ @pytest.fixture(autouse=True)
30
+ def one_hydration_at_a_time(monkeypatch):
31
+ """The fake sessions replay responses in CALL order, and parallel
32
+ hydrations would race for them, so tests hydrate one run at a time.
33
+ The parallel path has its own tests, with a session keyed on the window
34
+ each request asks for (``test_v2.WindowSession``)."""
35
+ from quantpad_data import v2
36
+
37
+ monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 1)
@@ -164,6 +164,128 @@ def test_rate_limit_exposes_retry_after():
164
164
  assert caught.value.retry_after == 7
165
165
 
166
166
 
167
+ def _record_sleeps(monkeypatch) -> list[float]:
168
+ import quantpad_data.client as client_module
169
+
170
+ slept: list[float] = []
171
+ monkeypatch.setattr(client_module.time, "sleep", slept.append)
172
+ return slept
173
+
174
+
175
+ def test_a_503_that_says_when_to_come_back_is_believed(monkeypatch):
176
+ """During the 2026-10-01/02 API-key outage every answer was "503,
177
+ Retry-After: 5", and this client retried each call three more times
178
+ inside four seconds -- one key kept that up for sixteen hours."""
179
+ from quantpad_data.errors import UpstreamError
180
+
181
+ slept = _record_sleeps(monkeypatch)
182
+ unavailable = b'{"error":"API-key validation is temporarily unavailable"}'
183
+ session = FakeSession(
184
+ [response(503, unavailable, {"Retry-After": "5"}) for _ in range(4)]
185
+ )
186
+ client = Client(api_key="secret", session=session, max_retries=3)
187
+
188
+ with pytest.raises(UpstreamError) as caught:
189
+ client.get_coverage("AAPL")
190
+
191
+ assert caught.value.retry_after == 5
192
+ assert len(session.calls) == 4, "retries are capped at max_retries"
193
+ assert len(slept) == 3
194
+ assert all(delay >= 5 for delay in slept), slept
195
+
196
+
197
+ def test_backoff_doubles_with_jitter_when_the_server_names_no_time(monkeypatch):
198
+ from quantpad_data.client import RETRY_BACKOFF_BASE
199
+ from quantpad_data.errors import UpstreamError
200
+
201
+ slept = _record_sleeps(monkeypatch)
202
+ session = FakeSession([response(502, b"bad gateway") for _ in range(4)])
203
+ client = Client(api_key="secret", session=session, max_retries=3)
204
+
205
+ with pytest.raises(UpstreamError):
206
+ client.get_coverage("AAPL")
207
+
208
+ for attempt, delay in enumerate(slept):
209
+ step = RETRY_BACKOFF_BASE * 2**attempt
210
+ assert step / 2 <= delay <= step, (attempt, delay)
211
+
212
+
213
+ def test_jitter_actually_spreads_clients_out():
214
+ from quantpad_data.client import _retry_delay
215
+
216
+ assert len({round(_retry_delay(2, 5), 6) for _ in range(50)}) > 1
217
+ assert len({round(_retry_delay(2, None), 6) for _ in range(50)}) > 1
218
+
219
+
220
+ def test_an_absurd_retry_after_is_capped_rather_than_slept(monkeypatch):
221
+ """A daily-quota 429 can say "come back in twenty hours"; sleeping
222
+ that long without a word is worse than failing."""
223
+ from quantpad_data.client import RETRY_AFTER_MAX, _retry_delay
224
+
225
+ assert _retry_delay(0, 72_000) <= RETRY_AFTER_MAX + 1.0
226
+
227
+
228
+ def _trades_stream(*batch_rows: int, complete: bool) -> bytes:
229
+ """An Arrow IPC tick stream; ``complete=False`` stops before the
230
+ end-of-stream marker, as a body cut off by the connection would."""
231
+ schema = pa.schema([("t", pa.int64()), ("price", pa.float64())])
232
+ sink = io.BytesIO()
233
+ writer = ipc.new_stream(sink, schema)
234
+ for rows in batch_rows:
235
+ writer.write_batch(pa.record_batch(
236
+ [pa.array(range(rows), pa.int64()), pa.array([1.0] * rows)],
237
+ schema=schema,
238
+ ))
239
+ if complete:
240
+ writer.close()
241
+ return sink.getvalue()
242
+
243
+
244
+ class _CutConnection(io.BytesIO):
245
+ """A raw body that ends the way an aborted chunked response does:
246
+ after the bytes that arrived, the next read fails rather than
247
+ returning empty."""
248
+
249
+ def read(self, size=-1):
250
+ data = super().read(size)
251
+ if not data:
252
+ import urllib3
253
+
254
+ raise urllib3.exceptions.ProtocolError(
255
+ "Connection broken: IncompleteRead(0 bytes read)"
256
+ )
257
+ return data
258
+
259
+
260
+ def test_a_tick_stream_cut_short_raises_an_error_that_says_so():
261
+ """The service ends a tick stream that fails partway by closing the
262
+ connection -- its 200 has already gone out. What surfaced before was
263
+ urllib3's bare ProtocolError; a caller has to be told that what they
264
+ received is incomplete and that retrying is the answer."""
265
+ from quantpad_data.errors import IncompleteResponseError, UpstreamError
266
+
267
+ cut = response(200, b"")
268
+ cut.raw = _CutConnection(_trades_stream(4, 6, complete=False))
269
+ client = Client(api_key="secret", session=FakeSession([cut]), max_retries=0)
270
+
271
+ with pytest.raises(IncompleteResponseError) as caught:
272
+ list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
273
+
274
+ assert isinstance(caught.value, UpstreamError), "existing handlers must still catch it"
275
+ assert caught.value.rows_received == 10
276
+ assert "retry" in str(caught.value).lower()
277
+
278
+
279
+ def test_a_complete_tick_stream_is_unaffected():
280
+ whole = response(200, b"")
281
+ whole.raw = io.BytesIO(_trades_stream(4, 6, complete=True))
282
+ client = Client(api_key="secret", session=FakeSession([whole]), max_retries=0)
283
+
284
+ frames = list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
285
+
286
+ assert sum(len(frame) for frame in frames) == 10
287
+
288
+
167
289
  def test_universe_and_coverage_paths():
168
290
  session = FakeSession(
169
291
  [response(body=b'{"results":[]}'), response(body=b'{"symbol":"AAPL"}')]
@@ -10,6 +10,8 @@ month of depth data is a normal thing to do.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import threading
14
+
13
15
  import io
14
16
  import json
15
17
  from datetime import date, timedelta
@@ -473,11 +475,20 @@ class TestBulkDelivery:
473
475
  dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
474
476
  start="2026-01-05", end="2026-01-07",
475
477
  )
478
+ # The fetch starts in the background straight away now, so it is
479
+ # held open: the cached frame must arrive without waiting for it.
480
+ gate = threading.Event()
481
+ replay = session.request
482
+
483
+ def held(method, url, **kwargs):
484
+ if session.calls: # every call after the survey is the fetch
485
+ assert gate.wait(5), "the cached frame never arrived"
486
+ return replay(method, url, **kwargs)
487
+
488
+ session.request = held
476
489
  first = next(frames)
477
490
  assert first.attrs["quantpad"]["day"] == "2026-01-05"
478
- # One call so far: the cached day was served without the fetch
479
- # for the missing one having been made.
480
- assert len(session.calls) == 1
491
+ gate.set()
481
492
  rest = list(frames)
482
493
  assert [f.attrs["quantpad"]["day"] for f in rest] == ["2026-01-06"]
483
494
  assert len(session.calls) == 2
@@ -1471,7 +1482,21 @@ class TestTheCallerDeclaresWhatItCanHold:
1471
1482
  )
1472
1483
 
1473
1484
  assert self._sent(session)["max_decoded_bytes"] == int(
1474
- self.FREE / memory.PEAK_PER_PRICED_BYTE
1485
+ self.FREE / memory.peak_per_priced_byte("mbp-10")
1486
+ )
1487
+
1488
+ def test_the_divisor_is_the_schemas_measured_factor(self):
1489
+ """6x refused single quote-record days that peak at ~3.2x."""
1490
+ for schema, factor in [("mbp-10", 3.0), ("mbp-1", 4.5), ("trades", 6.0),
1491
+ ("definition", 6.0), (None, 6.0)]:
1492
+ assert memory.declared_budget("auto", schema) == int(self.FREE / factor)
1493
+ assert memory.declared_budget("interactive", "mbp-1") == self.FREE
1494
+
1495
+ def test_every_measured_factor_stays_above_its_reading(self):
1496
+ for schema, reading in {"mbp-10": 2.57, "mbp-1": 4.32}.items():
1497
+ assert memory.peak_per_priced_byte(schema) > reading
1498
+ assert all(
1499
+ f <= memory.PEAK_PER_PRICED_BYTE for f in memory.PEAK_BY_SCHEMA.values()
1475
1500
  )
1476
1501
 
1477
1502
  def test_interactive_declares_the_undivided_figure(self):
@@ -2297,4 +2322,126 @@ class TestDerivations:
2297
2322
  stype_in="continuous", start="2026-01-05", end="2026-02-05",
2298
2323
  resample="4h",
2299
2324
  ))
2300
- assert session.calls == []
2325
+ assert session.calls == []
2326
+
2327
+
2328
+ class WindowSession(FakeSession):
2329
+ """Answers each manifest by the window it asks for, not by call order,
2330
+ because parallel hydrations reach it in whatever order their threads
2331
+ run. The first ``parties`` fetches wait at a barrier until all of them
2332
+ are in flight -- the proof that they overlap."""
2333
+
2334
+ def __init__(self, survey, parties=None):
2335
+ super().__init__([])
2336
+ self.survey = survey
2337
+ self.lock = threading.Lock()
2338
+ self.barrier = threading.Barrier(parties) if parties else None
2339
+ self.fetches = []
2340
+
2341
+ def request(self, method, url, **kwargs):
2342
+ body = kwargs.get("json") or {}
2343
+ with self.lock:
2344
+ self.calls.append((method, url, kwargs))
2345
+ if body.get("hydrate") is False:
2346
+ return self.survey
2347
+ self.fetches.append((body["start"], body["end"]))
2348
+ first_wave = len(self.fetches) <= (self.barrier.parties if self.barrier else 0)
2349
+ if first_wave:
2350
+ self.barrier.wait(5)
2351
+ start, end = date.fromisoformat(body["start"]), date.fromisoformat(body["end"])
2352
+ days = [(start + timedelta(days=i)).isoformat() for i in range((end - start).days)]
2353
+ return json_response({
2354
+ "format": "parquet",
2355
+ "files": [{"day": d, "instrument_id": 1, "key": d, "size_bytes": 1,
2356
+ "url": f"https://r2.test/{d}"} for d in days],
2357
+ "gaps": [],
2358
+ "metadata": {},
2359
+ })
2360
+
2361
+
2362
+ class TestParallelHydration:
2363
+ """Sequential hydration made a cold window cost the SUM of its fetches
2364
+ (99.0s against 33.6s four at a time, measured on production)."""
2365
+
2366
+ GAPS = [f"2026-01-{day:02d}" for day in range(5, 13)] # runs of 1, 2, 4, 1
2367
+
2368
+ def _iter(self, monkeypatch, session, end="2026-01-13"):
2369
+ from quantpad_data import v2
2370
+
2371
+ client = Client(api_key="k", base_url="https://api.test", session=session)
2372
+ monkeypatch.setattr(
2373
+ v2, "_read_parquet_url", lambda url, columns: pd.DataFrame({"url": [url]})
2374
+ )
2375
+ return Historical(client).timeseries.iter_range(
2376
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
2377
+ start="2026-01-05", end=end,
2378
+ )
2379
+
2380
+ def _survey(self, gaps, cached=()):
2381
+ return json_response({
2382
+ "format": "parquet",
2383
+ "files": [{"day": d, "instrument_id": 1, "key": d, "size_bytes": 1,
2384
+ "url": f"https://r2.test/{d}"} for d in cached],
2385
+ "gaps": [{"day": d, "instrument_id": 1} for d in gaps],
2386
+ "metadata": {},
2387
+ })
2388
+
2389
+ def test_cold_runs_hydrate_at_the_same_time(self, monkeypatch):
2390
+ from quantpad_data import v2
2391
+
2392
+ monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 3)
2393
+ session = WindowSession(self._survey(self.GAPS), parties=3)
2394
+
2395
+ days = [f.attrs["quantpad"]["day"] for f in self._iter(monkeypatch, session)]
2396
+
2397
+ assert days == self.GAPS
2398
+ assert len(session.fetches) == 4
2399
+
2400
+ def test_the_window_stays_in_order_with_cached_days_in_place(self, monkeypatch):
2401
+ from quantpad_data import v2
2402
+
2403
+ monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 4)
2404
+ gaps = ["2026-01-05", "2026-01-07", "2026-01-08", "2026-01-10"]
2405
+ cached = ["2026-01-06", "2026-01-09"]
2406
+ session = WindowSession(self._survey(gaps, cached))
2407
+
2408
+ days = [f.attrs["quantpad"]["day"]
2409
+ for f in self._iter(monkeypatch, session, end="2026-01-11")]
2410
+
2411
+ assert days == sorted(gaps + cached)
2412
+
2413
+ def test_an_early_close_leaves_nothing_that_blocks_exit(self, monkeypatch):
2414
+ """A hydration in flight cannot be cancelled, and pool workers are
2415
+ joined at interpreter exit -- so hydrations run on daemon threads,
2416
+ which a script that stops iterating early can exit past."""
2417
+ from quantpad_data import v2
2418
+
2419
+ monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 2)
2420
+ gate, started = threading.Event(), threading.Event()
2421
+ session = WindowSession(self._survey(self.GAPS))
2422
+ replay = session.request
2423
+
2424
+ def hanging(method, url, **kwargs):
2425
+ body = kwargs.get("json") or {}
2426
+ if body.get("hydrate") is not False and body["start"] != "2026-01-05":
2427
+ started.set()
2428
+ gate.wait(10)
2429
+ return replay(method, url, **kwargs)
2430
+
2431
+ session.request = hanging
2432
+ frames = self._iter(monkeypatch, session)
2433
+ assert next(frames).attrs["quantpad"]["day"] == "2026-01-05"
2434
+ assert started.wait(5)
2435
+ frames.close()
2436
+ hydrating = [t for t in threading.enumerate() if t.name == "qpd-hydrate"]
2437
+ assert hydrating and all(t.daemon for t in hydrating)
2438
+ gate.set()
2439
+
2440
+ def test_stopping_early_does_not_wait_on_queued_hydrations(self, monkeypatch):
2441
+ from quantpad_data import v2
2442
+
2443
+ monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 2)
2444
+ frames = self._iter(monkeypatch, WindowSession(self._survey(self.GAPS)))
2445
+
2446
+ assert next(frames).attrs["quantpad"]["day"] == "2026-01-05"
2447
+ frames.close()
@@ -124,12 +124,13 @@ def api(*responses):
124
124
  return Historical(client).timeseries, session
125
125
 
126
126
 
127
- def manifest_for(days, instrument_id=1551):
127
+ def manifest_for(days, instrument_id=1551, version=None):
128
128
  return response(200, json.dumps({
129
129
  "format": "parquet",
130
130
  "files": [
131
131
  {"day": day, "instrument_id": instrument_id, "symbol": "BA",
132
132
  "key": f"k/{day}", "size_bytes": 10,
133
+ **({"version": version} if version is not None else {}),
133
134
  "url": f"http://r2.test/{day}.parquet"}
134
135
  for day in days
135
136
  ],
@@ -276,6 +277,17 @@ class TestWhatTheKeyMustSeparate:
276
277
  base = _body(**ASK)
277
278
  assert cache.range_path(base) == cache.range_path({**base, field: value})
278
279
 
280
+ def test_an_answer_cached_before_the_service_changed_it_is_not_served(
281
+ self, monkeypatch
282
+ ):
283
+ # Until 2026-10 a window ending mid-day came back without its last
284
+ # day, and was cached that way. The body has not changed, so only the
285
+ # epoch can tell the old short answer from the new one.
286
+ body = _body(**{**ASK, "end": "2026-09-16T13:58:00Z"})
287
+ current = cache.range_path(body)
288
+ monkeypatch.setattr(cache, "_RANGE_KEY_EPOCH", cache._RANGE_KEY_EPOCH - 1)
289
+ assert cache.range_path(body) != current
290
+
279
291
 
280
292
  class TestWhatMustNotBeCached:
281
293
  def test_a_window_reaching_into_the_current_day_is_not_cached(self):
@@ -546,6 +558,70 @@ class TestPartitionsAreCachedIndividually:
546
558
  )
547
559
 
548
560
 
561
+ class TestACorrectedDayIsNotServedFromDisk:
562
+ """A settled day is final for the calendar, not for the vendor.
563
+
564
+ The service re-fetches a day its vendor regenerates and changes the
565
+ manifest item's ``version`` when it does. Before this, a day already on
566
+ disk was never asked about again, so the correction reached nobody who
567
+ had read the day once -- and nothing anywhere said the copy was old.
568
+ """
569
+
570
+ CORRECTED = ROWS.assign(price=[100.5, 101.5])
571
+
572
+ def test_the_same_version_is_still_a_hit(self, monkeypatch):
573
+ urls = UrlRecorder()
574
+ monkeypatch.setattr(requests, "get", urls)
575
+ ts, _ = api(
576
+ manifest_for(["2026-09-16"], version="v1"),
577
+ manifest_for(["2026-09-16"], version="v1"),
578
+ )
579
+ list(ts.iter_range(**_manifest_ask()))
580
+ list(ts.iter_range(**_manifest_ask()))
581
+ assert len(urls.urls) == 1
582
+
583
+ def test_a_new_version_is_fetched_and_replaces_the_copy(self, monkeypatch):
584
+ urls = UrlRecorder()
585
+ monkeypatch.setattr(requests, "get", urls)
586
+ ts, _ = api(
587
+ manifest_for(["2026-09-16"], version="v1"),
588
+ manifest_for(["2026-09-16"], version="v2"),
589
+ manifest_for(["2026-09-16"], version="v2"),
590
+ )
591
+ list(ts.iter_range(**_manifest_ask()))
592
+ urls.frame = self.CORRECTED
593
+ [corrected] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
594
+ [again] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
595
+
596
+ assert len(urls.urls) == 2, "fetched once per version, not per read"
597
+ assert list(corrected["price"]) == [100.5, 101.5]
598
+ pd.testing.assert_frame_equal(corrected, again)
599
+
600
+ def test_a_copy_written_before_versions_existed_is_refetched_once(
601
+ self, monkeypatch
602
+ ):
603
+ """Every entry on disk today carries no version, and some of them
604
+ are the uncorrected days this exists for."""
605
+ urls = UrlRecorder()
606
+ monkeypatch.setattr(requests, "get", urls)
607
+ ts, _ = api(
608
+ manifest_for(["2026-09-16"]),
609
+ manifest_for(["2026-09-16"], version="v1"),
610
+ manifest_for(["2026-09-16"], version="v1"),
611
+ )
612
+ for _ in range(3):
613
+ list(ts.iter_range(**_manifest_ask()))
614
+ assert len(urls.urls) == 2
615
+
616
+ def test_a_range_entry_is_not_asked_for_a_version_it_never_had(self):
617
+ """``expect`` is opt-in: the whole-response cache has no per-file
618
+ version to compare, and must keep its hits."""
619
+ path = cache.range_path(_body(**ASK))
620
+ assert cache.put_frame(path, ROWS, {"echo": {}})
621
+ assert cache.get_frame(path) is not None
622
+ assert cache.get_frame(path, expect={"version": "v1"}) is None
623
+
624
+
549
625
  class TestTheSettlementRuleItself:
550
626
  def test_a_past_day_is_settled(self):
551
627
  assert cache.day_settled("2026-09-16") is True
File without changes
File without changes