quantpad-data 0.7.1__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.1 → quantpad_data-0.8.0}/PKG-INFO +6 -2
  2. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/README.md +5 -1
  3. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/pyproject.toml +1 -1
  4. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/__init__.py +5 -1
  5. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/client.py +1 -1
  6. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/v2.py +50 -1
  7. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_v2.py +73 -0
  8. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/.gitignore +0 -0
  9. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/cache.py +0 -0
  10. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/errors.py +0 -0
  11. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/lean.py +0 -0
  12. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/memory.py +0 -0
  13. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/py.typed +0 -0
  14. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/quantpad_data/rth.py +0 -0
  15. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/conftest.py +0 -0
  16. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_client.py +0 -0
  17. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_lean.py +0 -0
  18. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_message_encoding.py +0 -0
  19. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_packaging.py +0 -0
  20. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_refusal_hint.py +0 -0
  21. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_rth.py +0 -0
  22. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_v2_cache.py +0 -0
  23. {quantpad_data-0.7.1 → quantpad_data-0.8.0}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.7.1 → 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.1
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.1"
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.1"
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",
@@ -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):
@@ -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