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.
Files changed (24) hide show
  1. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/PKG-INFO +6 -2
  2. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/README.md +5 -1
  3. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/pyproject.toml +1 -1
  4. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/__init__.py +5 -1
  5. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/client.py +29 -2
  6. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/v2.py +50 -1
  7. quantpad_data-0.8.0/tests/test_refusal_hint.py +71 -0
  8. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2.py +73 -0
  9. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/.gitignore +0 -0
  10. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/cache.py +0 -0
  11. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/errors.py +0 -0
  12. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/lean.py +0 -0
  13. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/memory.py +0 -0
  14. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/py.typed +0 -0
  15. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/quantpad_data/rth.py +0 -0
  16. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/conftest.py +0 -0
  17. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_client.py +0 -0
  18. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_lean.py +0 -0
  19. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_message_encoding.py +0 -0
  20. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_packaging.py +0 -0
  21. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_rth.py +0 -0
  22. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2_cache.py +0 -0
  23. {quantpad_data-0.7.0 → quantpad_data-0.8.0}/tests/test_v2_gate.py +0 -0
  24. {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.7.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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.7.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 str(error.get("message") or error), error
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 pd.DataFrame(payload.get("contracts", []))
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