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.
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/PKG-INFO +3 -4
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/README.md +2 -3
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/pyproject.toml +1 -1
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/__init__.py +3 -2
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/cache.py +21 -3
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/client.py +78 -6
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/errors.py +33 -1
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/memory.py +1 -1
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/v2.py +23 -27
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_client.py +122 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_packaging.py +38 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2.py +3 -3
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2_cache.py +77 -1
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/.gitignore +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/conftest.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_lean.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_refusal_hint.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_rth.py +0 -0
- {quantpad_data-0.8.0 → quantpad_data-0.8.3}/tests/test_v2_gate.py +0 -0
- {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.
|
|
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.
|
|
110
|
-
|
|
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.
|
|
88
|
-
|
|
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
|
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
771
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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,
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|