quantpad-data 0.7.1__tar.gz → 0.8.1__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.1 → quantpad_data-0.8.1}/PKG-INFO +8 -5
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/README.md +7 -4
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/pyproject.toml +1 -1
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/__init__.py +6 -2
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/client.py +1 -1
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/memory.py +1 -1
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/v2.py +64 -26
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_packaging.py +38 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_v2.py +76 -3
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/.gitignore +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/conftest.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_client.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_lean.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_refusal_hint.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_rth.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.7.1 → quantpad_data-0.8.1}/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.1
|
|
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
|
|
@@ -243,7 +242,11 @@ cache.clear() # throw it all away
|
|
|
243
242
|
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
244
243
|
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
245
244
|
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
246
|
-
downloading a day's entire `definition` schema.
|
|
245
|
+
downloading a day's entire `definition` schema. A chain is capped at `limit`
|
|
246
|
+
(5,000 by default, 50,000 at most); the frame's `attrs` carry the server's
|
|
247
|
+
`count`, `total` and `truncated`, and a cut chain raises
|
|
248
|
+
`TruncatedChainWarning` — `warnings.simplefilter("error",
|
|
249
|
+
qpd.TruncatedChainWarning)` makes a partial chain a hard failure.
|
|
247
250
|
|
|
248
251
|
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
249
252
|
rather than metered per request.
|
|
@@ -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
|
|
@@ -221,7 +220,11 @@ cache.clear() # throw it all away
|
|
|
221
220
|
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
222
221
|
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
223
222
|
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
224
|
-
downloading a day's entire `definition` schema.
|
|
223
|
+
downloading a day's entire `definition` schema. A chain is capped at `limit`
|
|
224
|
+
(5,000 by default, 50,000 at most); the frame's `attrs` carry the server's
|
|
225
|
+
`count`, `total` and `truncated`, and a cut chain raises
|
|
226
|
+
`TruncatedChainWarning` — `warnings.simplefilter("error",
|
|
227
|
+
qpd.TruncatedChainWarning)` makes a partial chain a hard failure.
|
|
225
228
|
|
|
226
229
|
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
227
230
|
rather than metered per request.
|
|
@@ -198,18 +198,22 @@ def iter_l1_mbp1_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
|
198
198
|
|
|
199
199
|
from . import cache # noqa: E402 (submodule: on-disk cache for settled /v2)
|
|
200
200
|
from . import lean # noqa: E402 (submodule: LEAN backtesting data staging)
|
|
201
|
-
from . import v2 # noqa: E402 (submodule:
|
|
201
|
+
from . import v2 # noqa: E402 (submodule: explicit-address API)
|
|
202
202
|
|
|
203
203
|
# Exported at the root as well as on ``v2``: the sandbox module reaches it
|
|
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",
|
|
@@ -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
|
|
@@ -38,6 +27,7 @@ import io
|
|
|
38
27
|
import json
|
|
39
28
|
import os
|
|
40
29
|
import tempfile
|
|
30
|
+
import warnings
|
|
41
31
|
from datetime import date, timedelta
|
|
42
32
|
from typing import Any, Iterator, Sequence
|
|
43
33
|
|
|
@@ -254,7 +244,7 @@ class _Namespace:
|
|
|
254
244
|
|
|
255
245
|
|
|
256
246
|
class Timeseries(_Namespace):
|
|
257
|
-
"""
|
|
247
|
+
"""Ranges of bars, ticks and book depth for an explicit address."""
|
|
258
248
|
|
|
259
249
|
def get_range(
|
|
260
250
|
self,
|
|
@@ -762,12 +752,12 @@ class Timeseries(_Namespace):
|
|
|
762
752
|
|
|
763
753
|
|
|
764
754
|
class Symbology(_Namespace):
|
|
765
|
-
"""
|
|
755
|
+
"""Symbol resolution, plus a way in.
|
|
766
756
|
|
|
767
757
|
Every other call here demands an explicit ``dataset``, which is the
|
|
768
758
|
point and also a dead end for someone holding the string "AAPL".
|
|
769
|
-
``search`` is the one endpoint that answers that
|
|
770
|
-
|
|
759
|
+
``search`` is the one endpoint that answers that; the rest of this
|
|
760
|
+
surface assumes you already know.
|
|
771
761
|
"""
|
|
772
762
|
|
|
773
763
|
def search(
|
|
@@ -829,7 +819,7 @@ class Symbology(_Namespace):
|
|
|
829
819
|
|
|
830
820
|
|
|
831
821
|
class Metadata(_Namespace):
|
|
832
|
-
"""
|
|
822
|
+
"""Datasets, schemas, fields, coverage and request sizing.
|
|
833
823
|
|
|
834
824
|
The cost and unit-price endpoints are deliberately absent: QuantPad
|
|
835
825
|
data is included in the plan rather than metered, so a per-request
|
|
@@ -920,13 +910,61 @@ class Metadata(_Namespace):
|
|
|
920
910
|
return self._client._request("GET", path, params=clean).json()
|
|
921
911
|
|
|
922
912
|
|
|
913
|
+
class TruncatedChainWarning(UserWarning):
|
|
914
|
+
"""``options.chain`` returned fewer contracts than the filters matched.
|
|
915
|
+
|
|
916
|
+
The service caps a chain at ``limit`` (5,000 by default, 50,000 at
|
|
917
|
+
most) and says so in the body; this is that flag made audible. Raise
|
|
918
|
+
``limit`` or narrow the filters (``expiration``, ``strike_min``/
|
|
919
|
+
``strike_max``, ``instrument_class``) for the whole chain.
|
|
920
|
+
"""
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _chain_frame(payload: dict) -> pd.DataFrame:
|
|
924
|
+
"""The chain as a frame, carrying the server's count beside the rows.
|
|
925
|
+
|
|
926
|
+
The route answers ``count`` (rows in the body), ``total`` (rows the
|
|
927
|
+
filters matched) and ``truncated`` beside ``contracts``. Until this
|
|
928
|
+
the frame was built from ``contracts`` alone and the other three were
|
|
929
|
+
dropped, so a notebook handed exactly 5,000 rows could not tell a cut
|
|
930
|
+
chain from a whole one -- verified on prod, where an unfiltered
|
|
931
|
+
SPY.OPT came back as exactly the default limit against ~12,470
|
|
932
|
+
instruments. The three ride on ``frame.attrs`` (the same place
|
|
933
|
+
``get_range`` keeps its echo) and a truncation is also warned, since
|
|
934
|
+
``attrs`` is invisible to anyone not already looking for it.
|
|
935
|
+
|
|
936
|
+
Older servers send no ``total``; it is then unknown rather than
|
|
937
|
+
guessed, and ``truncated`` is read as the server sent it.
|
|
938
|
+
"""
|
|
939
|
+
contracts = payload.get("contracts", [])
|
|
940
|
+
frame = pd.DataFrame(contracts)
|
|
941
|
+
truncated = bool(payload.get("truncated", False))
|
|
942
|
+
frame.attrs["count"] = payload.get("count", len(contracts))
|
|
943
|
+
frame.attrs["total"] = payload.get("total")
|
|
944
|
+
frame.attrs["truncated"] = truncated
|
|
945
|
+
if truncated:
|
|
946
|
+
total = frame.attrs["total"]
|
|
947
|
+
matched = f"{total:,} matched" if isinstance(total, int) else "more matched"
|
|
948
|
+
warnings.warn(
|
|
949
|
+
f"options.chain returned {len(contracts):,} contracts but "
|
|
950
|
+
f"{matched}: the chain was cut at the service's limit. Raise "
|
|
951
|
+
"limit= (up to 50,000) or narrow the filters to see the rest.",
|
|
952
|
+
TruncatedChainWarning,
|
|
953
|
+
stacklevel=3,
|
|
954
|
+
)
|
|
955
|
+
return frame
|
|
956
|
+
|
|
957
|
+
|
|
923
958
|
class Options(_Namespace):
|
|
924
959
|
"""Options-chain enumeration.
|
|
925
960
|
|
|
926
|
-
Not part of
|
|
927
|
-
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
|
|
928
963
|
that server-side, because "download every instrument definition for
|
|
929
964
|
the day" is a poor answer to "what strikes exist".
|
|
965
|
+
|
|
966
|
+
The frame's ``attrs`` carry ``count``, ``total`` and ``truncated``;
|
|
967
|
+
a truncated chain also raises :class:`TruncatedChainWarning`.
|
|
930
968
|
"""
|
|
931
969
|
|
|
932
970
|
def chain(
|
|
@@ -955,11 +993,11 @@ class Options(_Namespace):
|
|
|
955
993
|
payload = self._client._request(
|
|
956
994
|
"GET", "/v2/options/chain", params=clean
|
|
957
995
|
).json()
|
|
958
|
-
return
|
|
996
|
+
return _chain_frame(payload)
|
|
959
997
|
|
|
960
998
|
|
|
961
999
|
class Batch(_Namespace):
|
|
962
|
-
"""
|
|
1000
|
+
"""Batch extracts, scoped to your own jobs.
|
|
963
1001
|
|
|
964
1002
|
Useful when a range is too large to stream in one sitting: submit,
|
|
965
1003
|
walk away, then hydrate the result into the cache so ordinary
|
|
@@ -1010,7 +1048,7 @@ class Batch(_Namespace):
|
|
|
1010
1048
|
|
|
1011
1049
|
|
|
1012
1050
|
class Historical:
|
|
1013
|
-
"""
|
|
1051
|
+
"""An explicit-address view of QuantPad's cache.
|
|
1014
1052
|
|
|
1015
1053
|
Takes the same credentials as the v1 ``Client`` — an API key, or the
|
|
1016
1054
|
injected token when running inside a QuantPad sandbox.
|
|
@@ -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",
|
|
@@ -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
|