quantpad-data 0.7.0__tar.gz → 0.8.0__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.7.0 → quantpad_data-0.8.0}/PKG-INFO +6 -2
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/README.md +5 -1
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/pyproject.toml +1 -1
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/__init__.py +5 -1
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/client.py +29 -2
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/v2.py +50 -1
- quantpad_data-0.8.0/tests/test_refusal_hint.py +71 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2.py +73 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/.gitignore +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/memory.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/conftest.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_client.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_lean.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_packaging.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_rth.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quantpad-data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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
|
|
@@ -243,7 +243,11 @@ cache.clear() # throw it all away
|
|
|
243
243
|
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
244
244
|
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
245
245
|
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
246
|
-
downloading a day's entire `definition` schema.
|
|
246
|
+
downloading a day's entire `definition` schema. A chain is capped at `limit`
|
|
247
|
+
(5,000 by default, 50,000 at most); the frame's `attrs` carry the server's
|
|
248
|
+
`count`, `total` and `truncated`, and a cut chain raises
|
|
249
|
+
`TruncatedChainWarning` — `warnings.simplefilter("error",
|
|
250
|
+
qpd.TruncatedChainWarning)` makes a partial chain a hard failure.
|
|
247
251
|
|
|
248
252
|
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
249
253
|
rather than metered per request.
|
|
@@ -221,7 +221,11 @@ cache.clear() # throw it all away
|
|
|
221
221
|
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
222
222
|
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
223
223
|
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
224
|
-
downloading a day's entire `definition` schema.
|
|
224
|
+
downloading a day's entire `definition` schema. A chain is capped at `limit`
|
|
225
|
+
(5,000 by default, 50,000 at most); the frame's `attrs` carry the server's
|
|
226
|
+
`count`, `total` and `truncated`, and a cut chain raises
|
|
227
|
+
`TruncatedChainWarning` — `warnings.simplefilter("error",
|
|
228
|
+
qpd.TruncatedChainWarning)` makes a partial chain a hard failure.
|
|
225
229
|
|
|
226
230
|
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
227
231
|
rather than metered per request.
|
|
@@ -204,12 +204,16 @@ from . import v2 # noqa: E402 (submodule: Databento-shaped explicit API)
|
|
|
204
204
|
# as ``qpd.Historical()``, and one spelling across both surfaces means
|
|
205
205
|
# notebook code moves between them unchanged.
|
|
206
206
|
Historical = v2.Historical
|
|
207
|
+
# At the root for the same reason: ``warnings.simplefilter("error",
|
|
208
|
+
# qpd.TruncatedChainWarning)`` is how a script turns a cut chain into a
|
|
209
|
+
# failure, and it should not need to know which submodule warns.
|
|
210
|
+
TruncatedChainWarning = v2.TruncatedChainWarning
|
|
207
211
|
|
|
208
212
|
__all__ = [
|
|
209
213
|
"Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
|
|
210
214
|
"PermissionDeniedError", "RateLimitError", "QuotaExceededError",
|
|
211
215
|
"NotFoundError", "V2NotAvailableError", "ValidationError", "UpstreamError",
|
|
212
|
-
"ResponseTooLargeError", "TICK_SCHEMAS",
|
|
216
|
+
"ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
|
|
213
217
|
"ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
|
|
214
218
|
"get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
|
|
215
219
|
"get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
|
|
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
|
|
|
84
84
|
"QuantPad support about /v2 access."
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
VERSION = "0.
|
|
87
|
+
VERSION = "0.8.0"
|
|
88
88
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
89
89
|
TICK_SCHEMAS = (
|
|
90
90
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -143,6 +143,25 @@ BARS_TIMEOUT = (30, 1800)
|
|
|
143
143
|
V2_DATA_TIMEOUT = (10, 1800)
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def _prose_with_hint(error: dict) -> str:
|
|
147
|
+
"""The sentence to raise from a structured refusal.
|
|
148
|
+
|
|
149
|
+
``message`` first; a ``hint`` follows it as a second sentence when the
|
|
150
|
+
service sent one. Kept out of the message on the wire so a caller
|
|
151
|
+
reading the body can still tell the refusal from the advice, and put
|
|
152
|
+
back here because the exception text is the only thing most callers
|
|
153
|
+
ever see. A body with a hint and no message is stringified whole, as
|
|
154
|
+
it always was.
|
|
155
|
+
"""
|
|
156
|
+
message = error.get("message")
|
|
157
|
+
if not message:
|
|
158
|
+
return str(error)
|
|
159
|
+
hint = error.get("hint")
|
|
160
|
+
if isinstance(hint, str) and hint.strip():
|
|
161
|
+
return f"{message} {hint.strip()}"
|
|
162
|
+
return str(message)
|
|
163
|
+
|
|
164
|
+
|
|
146
165
|
class Client:
|
|
147
166
|
def __init__(
|
|
148
167
|
self,
|
|
@@ -189,6 +208,14 @@ class Client:
|
|
|
189
208
|
user where the service had written a sentence. So the prose is
|
|
190
209
|
unwrapped from ``message`` when there is one, and the object is
|
|
191
210
|
kept beside it for the callers that need to branch on it.
|
|
211
|
+
|
|
212
|
+
A ``hint`` rides with the prose. The /v2 404 for a symbol nothing
|
|
213
|
+
resolves carries what to send instead -- ``NQ.FUT`` with
|
|
214
|
+
``stype_in='parent'`` for a bare ``NQ`` -- as its own field so a
|
|
215
|
+
caller branching on the body can tell it from the message; but the
|
|
216
|
+
exception a notebook prints is the message, and advice a client
|
|
217
|
+
drops on the floor is advice nobody gets (the shape
|
|
218
|
+
``options.chain`` had with ``truncated``).
|
|
192
219
|
"""
|
|
193
220
|
try:
|
|
194
221
|
body = response.json()
|
|
@@ -198,7 +225,7 @@ class Client:
|
|
|
198
225
|
return str(body), None
|
|
199
226
|
error = body.get("error") or body.get("detail") or body
|
|
200
227
|
if isinstance(error, dict):
|
|
201
|
-
return
|
|
228
|
+
return _prose_with_hint(error), error
|
|
202
229
|
return str(error), None
|
|
203
230
|
|
|
204
231
|
@classmethod
|
|
@@ -38,6 +38,7 @@ import io
|
|
|
38
38
|
import json
|
|
39
39
|
import os
|
|
40
40
|
import tempfile
|
|
41
|
+
import warnings
|
|
41
42
|
from datetime import date, timedelta
|
|
42
43
|
from typing import Any, Iterator, Sequence
|
|
43
44
|
|
|
@@ -920,6 +921,51 @@ class Metadata(_Namespace):
|
|
|
920
921
|
return self._client._request("GET", path, params=clean).json()
|
|
921
922
|
|
|
922
923
|
|
|
924
|
+
class TruncatedChainWarning(UserWarning):
|
|
925
|
+
"""``options.chain`` returned fewer contracts than the filters matched.
|
|
926
|
+
|
|
927
|
+
The service caps a chain at ``limit`` (5,000 by default, 50,000 at
|
|
928
|
+
most) and says so in the body; this is that flag made audible. Raise
|
|
929
|
+
``limit`` or narrow the filters (``expiration``, ``strike_min``/
|
|
930
|
+
``strike_max``, ``instrument_class``) for the whole chain.
|
|
931
|
+
"""
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _chain_frame(payload: dict) -> pd.DataFrame:
|
|
935
|
+
"""The chain as a frame, carrying the server's count beside the rows.
|
|
936
|
+
|
|
937
|
+
The route answers ``count`` (rows in the body), ``total`` (rows the
|
|
938
|
+
filters matched) and ``truncated`` beside ``contracts``. Until this
|
|
939
|
+
the frame was built from ``contracts`` alone and the other three were
|
|
940
|
+
dropped, so a notebook handed exactly 5,000 rows could not tell a cut
|
|
941
|
+
chain from a whole one -- verified on prod, where an unfiltered
|
|
942
|
+
SPY.OPT came back as exactly the default limit against ~12,470
|
|
943
|
+
instruments. The three ride on ``frame.attrs`` (the same place
|
|
944
|
+
``get_range`` keeps its echo) and a truncation is also warned, since
|
|
945
|
+
``attrs`` is invisible to anyone not already looking for it.
|
|
946
|
+
|
|
947
|
+
Older servers send no ``total``; it is then unknown rather than
|
|
948
|
+
guessed, and ``truncated`` is read as the server sent it.
|
|
949
|
+
"""
|
|
950
|
+
contracts = payload.get("contracts", [])
|
|
951
|
+
frame = pd.DataFrame(contracts)
|
|
952
|
+
truncated = bool(payload.get("truncated", False))
|
|
953
|
+
frame.attrs["count"] = payload.get("count", len(contracts))
|
|
954
|
+
frame.attrs["total"] = payload.get("total")
|
|
955
|
+
frame.attrs["truncated"] = truncated
|
|
956
|
+
if truncated:
|
|
957
|
+
total = frame.attrs["total"]
|
|
958
|
+
matched = f"{total:,} matched" if isinstance(total, int) else "more matched"
|
|
959
|
+
warnings.warn(
|
|
960
|
+
f"options.chain returned {len(contracts):,} contracts but "
|
|
961
|
+
f"{matched}: the chain was cut at the service's limit. Raise "
|
|
962
|
+
"limit= (up to 50,000) or narrow the filters to see the rest.",
|
|
963
|
+
TruncatedChainWarning,
|
|
964
|
+
stacklevel=3,
|
|
965
|
+
)
|
|
966
|
+
return frame
|
|
967
|
+
|
|
968
|
+
|
|
923
969
|
class Options(_Namespace):
|
|
924
970
|
"""Options-chain enumeration.
|
|
925
971
|
|
|
@@ -927,6 +973,9 @@ class Options(_Namespace):
|
|
|
927
973
|
the whole ``definition`` schema and filter it yourself. This does
|
|
928
974
|
that server-side, because "download every instrument definition for
|
|
929
975
|
the day" is a poor answer to "what strikes exist".
|
|
976
|
+
|
|
977
|
+
The frame's ``attrs`` carry ``count``, ``total`` and ``truncated``;
|
|
978
|
+
a truncated chain also raises :class:`TruncatedChainWarning`.
|
|
930
979
|
"""
|
|
931
980
|
|
|
932
981
|
def chain(
|
|
@@ -955,7 +1004,7 @@ class Options(_Namespace):
|
|
|
955
1004
|
payload = self._client._request(
|
|
956
1005
|
"GET", "/v2/options/chain", params=clean
|
|
957
1006
|
).json()
|
|
958
|
-
return
|
|
1007
|
+
return _chain_frame(payload)
|
|
959
1008
|
|
|
960
1009
|
|
|
961
1010
|
class Batch(_Namespace):
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A structured refusal's ``hint`` reaches the exception text.
|
|
2
|
+
|
|
3
|
+
The /v2 404 for a symbol nothing resolves carries what to send instead as
|
|
4
|
+
its own ``hint`` field -- ``NQ.FUT with stype_in='parent'`` for a bare
|
|
5
|
+
``NQ`` -- so a caller reading the body can tell the refusal from the
|
|
6
|
+
advice. The client used to raise ``message`` alone, which is the shape
|
|
7
|
+
``options.chain`` had with ``truncated``: the server said the useful thing
|
|
8
|
+
and the SDK dropped it before anyone could read it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from quantpad_data.client import Client
|
|
19
|
+
from quantpad_data.errors import NotFoundError
|
|
20
|
+
|
|
21
|
+
MESSAGE = "None of the requested symbols resolve to an instrument on GLBX.MDP3 over this window."
|
|
22
|
+
HINT = "For NQ try NQ.FUT with stype_in='parent' (every contract in the chain)."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _response(body: dict, status: int = 404) -> requests.Response:
|
|
26
|
+
response = requests.Response()
|
|
27
|
+
response.status_code = status
|
|
28
|
+
response.url = "https://example.test/v2/timeseries/get_range"
|
|
29
|
+
response._content = json.dumps({"error": body}).encode()
|
|
30
|
+
response.headers["Content-Type"] = "application/json"
|
|
31
|
+
return response
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _client() -> Client:
|
|
35
|
+
return Client(api_key="k", base_url="https://example.test")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestTheHintIsRaisedWithTheMessage:
|
|
39
|
+
def test_the_exception_text_carries_both_sentences_in_order(self):
|
|
40
|
+
with pytest.raises(NotFoundError) as caught:
|
|
41
|
+
_client()._raise_for_status(
|
|
42
|
+
_response({"message": MESSAGE, "hint": HINT, "not_found": ["NQ"]}),
|
|
43
|
+
"/v2/timeseries/get_range",
|
|
44
|
+
)
|
|
45
|
+
text = str(caught.value)
|
|
46
|
+
assert text == f"{MESSAGE} {HINT}"
|
|
47
|
+
|
|
48
|
+
def test_the_structured_body_is_still_available_beside_it(self):
|
|
49
|
+
"""Branching callers read the fields; the hint joining the prose
|
|
50
|
+
must not take the fields away."""
|
|
51
|
+
message, detail = Client._envelope(
|
|
52
|
+
_response({"message": MESSAGE, "hint": HINT, "not_found": ["NQ"]})
|
|
53
|
+
)
|
|
54
|
+
assert detail["hint"] == HINT
|
|
55
|
+
assert detail["not_found"] == ["NQ"]
|
|
56
|
+
assert message.startswith(MESSAGE)
|
|
57
|
+
|
|
58
|
+
def test_a_refusal_without_a_hint_reads_exactly_as_before(self):
|
|
59
|
+
message, _ = Client._envelope(_response({"message": MESSAGE}))
|
|
60
|
+
assert message == MESSAGE
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize("hint", ["", " ", None, 7, ["not", "prose"]])
|
|
63
|
+
def test_a_blank_or_non_prose_hint_adds_nothing(self, hint):
|
|
64
|
+
message, _ = Client._envelope(_response({"message": MESSAGE, "hint": hint}))
|
|
65
|
+
assert message == MESSAGE
|
|
66
|
+
|
|
67
|
+
def test_a_hint_with_no_message_does_not_become_the_message(self):
|
|
68
|
+
"""A body with advice but no refusal is malformed; stringify it
|
|
69
|
+
whole as the client always has rather than promote the hint."""
|
|
70
|
+
message, _ = Client._envelope(_response({"hint": HINT}))
|
|
71
|
+
assert message == str({"hint": HINT})
|
|
@@ -2117,6 +2117,79 @@ class TestTheOtherNamespaces:
|
|
|
2117
2117
|
assert "strike_min" not in params
|
|
2118
2118
|
assert "instrument_class" not in params
|
|
2119
2119
|
|
|
2120
|
+
# chaintruncate: the route says when it cut the chain, and the frame
|
|
2121
|
+
# used to drop that. Verified on prod: unfiltered SPY.OPT returned
|
|
2122
|
+
# exactly 5,000 rows (the default limit) against ~12,470 instruments,
|
|
2123
|
+
# with no way to tell from the frame.
|
|
2124
|
+
|
|
2125
|
+
def test_a_cut_chain_warns_and_carries_the_servers_count(self):
|
|
2126
|
+
import warnings
|
|
2127
|
+
|
|
2128
|
+
api, _ = client_with([json_response({
|
|
2129
|
+
"count": 2, "total": 12470, "truncated": True,
|
|
2130
|
+
"contracts": [
|
|
2131
|
+
{"raw_symbol": "SPY 260116C00500000"},
|
|
2132
|
+
{"raw_symbol": "SPY 260116P00500000"},
|
|
2133
|
+
],
|
|
2134
|
+
})])
|
|
2135
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
2136
|
+
warnings.simplefilter("always")
|
|
2137
|
+
chain = api.options.chain(
|
|
2138
|
+
dataset="OPRA.PILLAR", underlier="SPY.OPT", date="2026-01-05",
|
|
2139
|
+
)
|
|
2140
|
+
assert len(chain) == 2
|
|
2141
|
+
assert chain.attrs["truncated"] is True
|
|
2142
|
+
assert chain.attrs["count"] == 2
|
|
2143
|
+
assert chain.attrs["total"] == 12470
|
|
2144
|
+
(warning,) = [
|
|
2145
|
+
w for w in caught if issubclass(w.category, quantpad_data.TruncatedChainWarning)
|
|
2146
|
+
]
|
|
2147
|
+
text = str(warning.message)
|
|
2148
|
+
assert "2 contracts" in text and "12,470 matched" in text
|
|
2149
|
+
assert "limit=" in text # the way out is named, not just the fact
|
|
2150
|
+
|
|
2151
|
+
def test_a_whole_chain_is_silent_and_says_so(self):
|
|
2152
|
+
import warnings
|
|
2153
|
+
|
|
2154
|
+
api, _ = client_with([json_response({
|
|
2155
|
+
"count": 1, "total": 1, "truncated": False,
|
|
2156
|
+
"contracts": [{"raw_symbol": "ESM6 C4800"}],
|
|
2157
|
+
})])
|
|
2158
|
+
with warnings.catch_warnings():
|
|
2159
|
+
warnings.simplefilter("error") # any warning here is a failure
|
|
2160
|
+
chain = api.options.chain(
|
|
2161
|
+
dataset="GLBX.MDP3", underlier="ES.OPT", date="2026-01-05",
|
|
2162
|
+
)
|
|
2163
|
+
assert chain.attrs["truncated"] is False
|
|
2164
|
+
assert chain.attrs["total"] == 1
|
|
2165
|
+
|
|
2166
|
+
def test_an_older_server_without_total_still_reports_the_cut(self):
|
|
2167
|
+
"""The deployed route sent ``truncated`` before it sent ``total``;
|
|
2168
|
+
a frame from it must still say it was cut, with the total unknown
|
|
2169
|
+
rather than invented."""
|
|
2170
|
+
import warnings
|
|
2171
|
+
|
|
2172
|
+
api, _ = client_with([json_response({
|
|
2173
|
+
"count": 1, "truncated": True, "contracts": [{"raw_symbol": "A"}],
|
|
2174
|
+
})])
|
|
2175
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
2176
|
+
warnings.simplefilter("always")
|
|
2177
|
+
chain = api.options.chain(
|
|
2178
|
+
dataset="GLBX.MDP3", underlier="ES.OPT", date="2026-01-05",
|
|
2179
|
+
)
|
|
2180
|
+
assert chain.attrs["truncated"] is True
|
|
2181
|
+
assert chain.attrs["total"] is None
|
|
2182
|
+
assert any(
|
|
2183
|
+
issubclass(w.category, quantpad_data.TruncatedChainWarning) for w in caught
|
|
2184
|
+
)
|
|
2185
|
+
|
|
2186
|
+
def test_the_truncation_warning_is_exported_at_the_root(self):
|
|
2187
|
+
"""``warnings.simplefilter("error", quantpad_data.TruncatedChainWarning)`` is
|
|
2188
|
+
how a script refuses to run on a partial chain; it needs the name
|
|
2189
|
+
without knowing which submodule warns."""
|
|
2190
|
+
assert quantpad_data.TruncatedChainWarning is quantpad_data.v2.TruncatedChainWarning
|
|
2191
|
+
assert issubclass(quantpad_data.TruncatedChainWarning, UserWarning)
|
|
2192
|
+
|
|
2120
2193
|
def test_batch_submit_and_hydrate_are_reachable(self):
|
|
2121
2194
|
api, session = client_with([
|
|
2122
2195
|
json_response({"job_id": "j1"}),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|