quantpad-data 0.8.0__tar.gz → 0.8.3__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.0 → quantpad_data-0.8.3}/PKG-INFO +3 -4
  2. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/README.md +2 -3
  3. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/pyproject.toml +1 -1
  4. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/__init__.py +3 -2
  5. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/cache.py +21 -3
  6. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/client.py +78 -6
  7. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/errors.py +33 -1
  8. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/memory.py +1 -1
  9. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/v2.py +23 -27
  10. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_client.py +122 -0
  11. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_packaging.py +38 -0
  12. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2.py +3 -3
  13. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2_cache.py +77 -1
  14. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/.gitignore +0 -0
  15. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/lean.py +0 -0
  16. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/py.typed +0 -0
  17. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/rth.py +0 -0
  18. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/conftest.py +0 -0
  19. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_lean.py +0 -0
  20. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_message_encoding.py +0 -0
  21. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_refusal_hint.py +0 -0
  22. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_rth.py +0 -0
  23. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.8.0 → quantpad_data-0.8.3}/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.0
3
+ Version: 0.8.3
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
@@ -106,9 +106,8 @@ symbol it is. That is convenient until it is wrong, and when it is wrong it
106
106
  fails quietly — asking for a specific futures contract returns an empty frame
107
107
  that looks exactly like a coverage gap.
108
108
 
109
- `qpd.v2.Historical` removes the inference. It takes the same arguments as
110
- `databento.Historical`, so code written against Databento ports over by
111
- swapping the client:
109
+ `qpd.v2.Historical` removes the inference. Every call names its dataset and
110
+ symbology type explicitly, and every response reports the address it used:
112
111
 
113
112
  ```python
114
113
  import quantpad_data as qpd
@@ -84,9 +84,8 @@ symbol it is. That is convenient until it is wrong, and when it is wrong it
84
84
  fails quietly — asking for a specific futures contract returns an empty frame
85
85
  that looks exactly like a coverage gap.
86
86
 
87
- `qpd.v2.Historical` removes the inference. It takes the same arguments as
88
- `databento.Historical`, so code written against Databento ports over by
89
- swapping the client:
87
+ `qpd.v2.Historical` removes the inference. Every call names its dataset and
88
+ symbology type explicitly, and every response reports the address it used:
90
89
 
91
90
  ```python
92
91
  import quantpad_data as qpd
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.8.0"
7
+ version = "0.8.3"
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,
@@ -198,7 +199,7 @@ def iter_l1_mbp1_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
198
199
 
199
200
  from . import cache # noqa: E402 (submodule: on-disk cache for settled /v2)
200
201
  from . import lean # noqa: E402 (submodule: LEAN backtesting data staging)
201
- from . import v2 # noqa: E402 (submodule: Databento-shaped explicit API)
202
+ from . import v2 # noqa: E402 (submodule: explicit-address API)
202
203
 
203
204
  # Exported at the root as well as on ``v2``: the sandbox module reaches it
204
205
  # as ``qpd.Historical()``, and one spelling across both surfaces means
@@ -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.0"
90
+ VERSION = "0.8.3"
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):
@@ -15,7 +15,7 @@ before fetching it, which is why it takes both.
15
15
 
16
16
  THE UNITS ARE NOT THE SAME, AND THAT IS THE WHOLE DIFFICULTY
17
17
  The service prices in DBN bytes: record count times record width, which is
18
- Databento's own billing arithmetic. This process pays for the compressed wire
18
+ upstream's own billing arithmetic. This process pays for the compressed wire
19
19
  bytes, an Arrow table decoded from them, and a pandas frame copied out of that
20
20
  table -- partly all three at once. Measured with
21
21
  scripts/measure_client_memory.py against production, peak RSS per priced byte
@@ -1,4 +1,4 @@
1
- """Databento-shaped access to QuantPad's cache.
1
+ """Explicit-address access to QuantPad's cache.
2
2
 
3
3
  The v1 helpers take QuantPad's own arguments and infer the rest: which
4
4
  dataset a symbol lives in, what kind of symbol it is, how far back to
@@ -7,18 +7,7 @@ it is wrong it fails quietly — a specific futures contract returns an
7
7
  empty frame that looks exactly like a coverage gap.
8
8
 
9
9
  This module removes the inference. Every call names its dataset and its
10
- symbology type, exactly as ``databento.Historical`` requires, and every
11
- response reports the address it actually used. Code written against the
12
- Databento client transfers over with the client swapped:
13
-
14
- import databento as db
15
- client = db.Historical(key=...)
16
- df = client.timeseries.get_range(
17
- dataset="GLBX.MDP3", symbols="ESZ6", schema="ohlcv-1m",
18
- start="2026-01-05", end="2026-01-06",
19
- ).to_df()
20
-
21
- becomes
10
+ symbology type, and every response reports the address it actually used:
22
11
 
23
12
  import quantpad_data as qpd
24
13
  client = qpd.v2.Historical()
@@ -27,9 +16,9 @@ becomes
27
16
  start="2026-01-05", end="2026-01-06",
28
17
  )
29
18
 
30
- The differences are deliberate and few: a DataFrame comes back directly
31
- rather than a DBNStore, and there is no cost or billing surface, because
32
- data is included in the QuantPad plan rather than metered per request.
19
+ A DataFrame comes back directly, and there is no cost or billing surface,
20
+ because data is included in the QuantPad plan rather than metered per
21
+ request.
33
22
  """
34
23
 
35
24
  from __future__ import annotations
@@ -255,7 +244,7 @@ class _Namespace:
255
244
 
256
245
 
257
246
  class Timeseries(_Namespace):
258
- """Mirrors ``databento.Historical.timeseries``."""
247
+ """Ranges of bars, ticks and book depth for an explicit address."""
259
248
 
260
249
  def get_range(
261
250
  self,
@@ -763,12 +752,12 @@ class Timeseries(_Namespace):
763
752
 
764
753
 
765
754
  class Symbology(_Namespace):
766
- """Mirrors ``databento.Historical.symbology``, plus a way in.
755
+ """Symbol resolution, plus a way in.
767
756
 
768
757
  Every other call here demands an explicit ``dataset``, which is the
769
758
  point and also a dead end for someone holding the string "AAPL".
770
- ``search`` is the one endpoint that answers that, and it has no
771
- upstream counterpart because Databento assumes you already know.
759
+ ``search`` is the one endpoint that answers that; the rest of this
760
+ surface assumes you already know.
772
761
  """
773
762
 
774
763
  def search(
@@ -830,7 +819,7 @@ class Symbology(_Namespace):
830
819
 
831
820
 
832
821
  class Metadata(_Namespace):
833
- """Mirrors ``databento.Historical.metadata``.
822
+ """Datasets, schemas, fields, coverage and request sizing.
834
823
 
835
824
  The cost and unit-price endpoints are deliberately absent: QuantPad
836
825
  data is included in the plan rather than metered, so a per-request
@@ -969,8 +958,8 @@ def _chain_frame(payload: dict) -> pd.DataFrame:
969
958
  class Options(_Namespace):
970
959
  """Options-chain enumeration.
971
960
 
972
- Not part of Databento's client surface — upstream expects you to pull
973
- the whole ``definition`` schema and filter it yourself. This does
961
+ Not part of the usual historical-client surface, which expects you to
962
+ pull the whole ``definition`` schema and filter it yourself. This does
974
963
  that server-side, because "download every instrument definition for
975
964
  the day" is a poor answer to "what strikes exist".
976
965
 
@@ -1008,7 +997,7 @@ class Options(_Namespace):
1008
997
 
1009
998
 
1010
999
  class Batch(_Namespace):
1011
- """Mirrors ``databento.Historical.batch``, scoped to your own jobs.
1000
+ """Batch extracts, scoped to your own jobs.
1012
1001
 
1013
1002
  Useful when a range is too large to stream in one sitting: submit,
1014
1003
  walk away, then hydrate the result into the cache so ordinary
@@ -1059,7 +1048,7 @@ class Batch(_Namespace):
1059
1048
 
1060
1049
 
1061
1050
  class Historical:
1062
- """A ``databento.Historical``-shaped view of QuantPad's cache.
1051
+ """An explicit-address view of QuantPad's cache.
1063
1052
 
1064
1053
  Takes the same credentials as the v1 ``Client`` — an API key, or the
1065
1054
  injected token when running inside a QuantPad sandbox.
@@ -1341,7 +1330,13 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1341
1330
  hostile ``QPD_CACHE_DIR`` can contain, and the only other way it could
1342
1331
  fail is a manifest item missing ``day`` or ``instrument_id``, which the
1343
1332
  caller subscripts for its own labels two lines later regardless.
1333
+
1334
+ A settled day is not immutable: the service re-fetches a day its
1335
+ vendor regenerates, and says so by changing the item's ``version``. A
1336
+ hit therefore has to be the version the manifest describes, or it is
1337
+ the uncorrected copy served from disk for as long as the disk keeps it.
1344
1338
  """
1339
+ expect = {"version": item.get("version")}
1345
1340
  path = None
1346
1341
  if cache.enabled() and cache.day_settled(item.get("day")):
1347
1342
  path = cache.day_path(
@@ -1354,14 +1349,15 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1354
1349
  )
1355
1350
 
1356
1351
  if path is not None:
1357
- hit = cache.get_frame(path)
1352
+ hit = cache.get_frame(path, expect=expect)
1358
1353
  if hit is not None:
1359
1354
  return hit
1360
1355
 
1361
1356
  frame = _read_parquet_url(item["url"], columns)
1362
1357
  if path is not None:
1363
1358
  cache.put_frame(
1364
- path, frame, {"day": item["day"], "dataset": body["dataset"]}
1359
+ path, frame,
1360
+ {"day": item["day"], "dataset": body["dataset"], **expect},
1365
1361
  )
1366
1362
  return frame
1367
1363
 
@@ -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"}')]
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import ast
23
23
  import os
24
+ import re
24
25
  import subprocess
25
26
  import sys
26
27
  import tarfile
@@ -53,6 +54,7 @@ def built(tmp_path_factory):
53
54
  # ./dist would make it skip in CI, where nothing has built there yet --
54
55
  # a test that skips exactly where it is needed.
55
56
  "wheel_path": wheel,
57
+ "sdist_path": sdist,
56
58
  }
57
59
 
58
60
 
@@ -182,3 +184,39 @@ class TestAFreshInstallExposesTheApi:
182
184
  f"a fresh install cannot answer for its own API:\n"
183
185
  f"{done.stdout}\n{done.stderr[-1500:]}"
184
186
  )
187
+
188
+
189
+ class TestNothingShippedNamesTheDataVendor:
190
+ """QuantPad presents its market data as its own, and a published package is
191
+ as public as a response: PyPI serves every file in both artifacts, the
192
+ README is the project page, and ``help()`` in a user's sandbox prints the
193
+ docstrings. So nothing shipped may name the vendor -- read out of the
194
+ artifacts, for the same reason as everything else in this file."""
195
+
196
+ VENDOR = re.compile(r"data[\s_-]?bento", re.IGNORECASE)
197
+
198
+ @staticmethod
199
+ def _texts(built, kind):
200
+ if kind == "wheel":
201
+ with zipfile.ZipFile(built["wheel_path"]) as archive:
202
+ for name in archive.namelist():
203
+ yield name, archive.read(name)
204
+ else:
205
+ with tarfile.open(built["sdist_path"]) as archive:
206
+ for member in archive.getmembers():
207
+ if member.isfile():
208
+ yield member.name, archive.extractfile(member).read()
209
+
210
+ @pytest.mark.parametrize("kind", ["wheel", "sdist"])
211
+ def test_no_shipped_file_names_the_vendor(self, built, kind):
212
+ offenders = [
213
+ name
214
+ for name, data in self._texts(built, kind)
215
+ if self.VENDOR.search(data.decode("utf-8", errors="replace"))
216
+ ]
217
+ assert not offenders, f"the {kind} names the data vendor in {offenders}"
218
+
219
+ def test_the_readme_is_in_what_was_checked(self, built):
220
+ """Guards the test above: the wheel's METADATA carries the README."""
221
+ names = [name for name, _ in self._texts(built, "wheel")]
222
+ assert any(name.endswith("METADATA") for name in names)
@@ -1,4 +1,4 @@
1
- """The Databento-shaped surface.
1
+ """The explicit-address surface.
2
2
 
3
3
  Two things are being checked. First that the wire calls carry exactly
4
4
  what the caller said and nothing the SDK invented — the whole reason
@@ -95,7 +95,7 @@ class TestNothingIsInferred:
95
95
  assert kwargs["json"]["symbols"] == ["ESZ6"]
96
96
 
97
97
  def test_a_single_symbol_string_is_sent_as_a_list(self):
98
- """Databento accepts either; normalising here keeps the server
98
+ """Upstream accepts either; normalising here keeps the server
99
99
  contract one shape instead of two."""
100
100
  api, session = client_with([response(200, arrow_bytes(bars_table()))])
101
101
  api.timeseries.get_range(
@@ -104,7 +104,7 @@ class TestNothingIsInferred:
104
104
  )
105
105
  assert session.calls[0][2]["json"]["symbols"] == ["ESZ6"]
106
106
 
107
- def test_the_default_stype_matches_databento_not_our_own_convention(self):
107
+ def test_the_default_stype_matches_upstream_not_our_own_convention(self):
108
108
  api, session = client_with([response(200, arrow_bytes(bars_table()))])
109
109
  api.timeseries.get_range(
110
110
  dataset="GLBX.MDP3", symbols="ESZ6", schema="ohlcv-1m",
@@ -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