quantpad-data 0.2.0__tar.gz → 0.3.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.3.0/.gitignore +21 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/PKG-INFO +9 -1
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/README.md +8 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/pyproject.toml +1 -1
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/quantpad_data/client.py +74 -5
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/quantpad_data/errors.py +24 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/tests/test_client.py +38 -0
- quantpad_data-0.3.0/tests/test_v2_gate.py +215 -0
- quantpad_data-0.2.0/.gitignore +0 -11
- quantpad_data-0.2.0/quantpad_data/__init__.py +0 -215
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/quantpad_data/v2.py +0 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/tests/test_lean.py +0 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/tests/test_v2.py +0 -0
- {quantpad_data-0.2.0 → quantpad_data-0.3.0}/tests/test_version.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
.env
|
|
5
|
+
.env.local
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
|
|
13
|
+
# Throwaway investigation probes. Named so they sort together and are
|
|
14
|
+
# obviously not part of the service; there are usually a lot of them
|
|
15
|
+
# mid-investigation and none of them should ever be committed.
|
|
16
|
+
_qp_*
|
|
17
|
+
|
|
18
|
+
# Scratch: commit-message and one-off investigation files.
|
|
19
|
+
_*.txt
|
|
20
|
+
_*.md
|
|
21
|
+
_*.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quantpad-data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -88,6 +88,14 @@ symbology. It does not proxy FRED or SEC EDGAR.
|
|
|
88
88
|
|
|
89
89
|
## Explicit addressing: `qpd.v2`
|
|
90
90
|
|
|
91
|
+
> **Not open yet.** `/v2` is limited to a small allowlist while it is
|
|
92
|
+
> finished, so everything in this section will raise
|
|
93
|
+
> `V2NotAvailableError` for you today — an API key is refused with 403
|
|
94
|
+
> and a notebook token with 404, whatever arguments you pass. Nothing
|
|
95
|
+
> above this section is affected: the `/v1` helpers carry the same data
|
|
96
|
+
> and are generally available. Contact QuantPad support if you need
|
|
97
|
+
> `/v2` access. This notice comes down when the gate does.
|
|
98
|
+
|
|
91
99
|
The helpers above infer which dataset a symbol lives in and what kind of
|
|
92
100
|
symbol it is. That is convenient until it is wrong, and when it is wrong it
|
|
93
101
|
fails quietly — asking for a specific futures contract returns an empty frame
|
|
@@ -67,6 +67,14 @@ symbology. It does not proxy FRED or SEC EDGAR.
|
|
|
67
67
|
|
|
68
68
|
## Explicit addressing: `qpd.v2`
|
|
69
69
|
|
|
70
|
+
> **Not open yet.** `/v2` is limited to a small allowlist while it is
|
|
71
|
+
> finished, so everything in this section will raise
|
|
72
|
+
> `V2NotAvailableError` for you today — an API key is refused with 403
|
|
73
|
+
> and a notebook token with 404, whatever arguments you pass. Nothing
|
|
74
|
+
> above this section is affected: the `/v1` helpers carry the same data
|
|
75
|
+
> and are generally available. Contact QuantPad support if you need
|
|
76
|
+
> `/v2` access. This notice comes down when the gate does.
|
|
77
|
+
|
|
70
78
|
The helpers above infer which dataset a symbol lives in and what kind of
|
|
71
79
|
symbol it is. That is convenient until it is wrong, and when it is wrong it
|
|
72
80
|
fails quietly — asking for a specific futures contract returns an empty frame
|
|
@@ -21,10 +21,51 @@ from .errors import (
|
|
|
21
21
|
QuotaExceededError,
|
|
22
22
|
RateLimitError,
|
|
23
23
|
UpstreamError,
|
|
24
|
+
V2NotAvailableError,
|
|
24
25
|
ValidationError,
|
|
25
26
|
)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
#: What the two ``/v2`` gates answer, verbatim.
|
|
29
|
+
#:
|
|
30
|
+
#: Matched on the exact message rather than on the status alone, because
|
|
31
|
+
#: ``/v2`` returns both of these for real reasons too: a batch job that
|
|
32
|
+
#: is not yours is a 404 ("No such batch job", deliberately, so job ids
|
|
33
|
+
#: cannot be enumerated), and a window outside your plan is a 403. Those
|
|
34
|
+
#: are answers about the request and must keep their own errors.
|
|
35
|
+
#:
|
|
36
|
+
#: The 404 is the service declining to confirm the feature exists. That
|
|
37
|
+
#: is right for a stranger and wrong for us — this SDK documents
|
|
38
|
+
#: ``qpd.v2`` in its own README, so there is nothing left to withhold
|
|
39
|
+
#: from someone who has already called it.
|
|
40
|
+
#:
|
|
41
|
+
#: Confirmed by causing both against production, 2026-09-14, rather than
|
|
42
|
+
#: by reading the router. Doing so turned up a distinction the source
|
|
43
|
+
#: does not show: the gate's 404 is rendered by the app's error handler
|
|
44
|
+
#: as ``{"error": "Not Found"}``, while a genuinely absent route falls
|
|
45
|
+
#: through to FastAPI's own ``{"detail": "Not Found"}``. Both reduce to
|
|
46
|
+
#: the same message here, so a typo'd ``/v2`` path — or any ``/v2``
|
|
47
|
+
#: route on a server older than this SDK — is also reported as the gate.
|
|
48
|
+
#: Left deliberately: while ``/v2`` is shut, every 404 on it does mean
|
|
49
|
+
#: "you cannot use this", and keying on which JSON field the body
|
|
50
|
+
#: happens to use would couple the SDK to a rendering detail. The
|
|
51
|
+
#: discriminator is recorded here in case that trade ever stops holding.
|
|
52
|
+
_V2_GATE_REPLIES = frozenset(
|
|
53
|
+
{
|
|
54
|
+
(403, "API keys cannot access this endpoint"),
|
|
55
|
+
(404, "Not Found"),
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
V2_NOT_AVAILABLE = (
|
|
60
|
+
"The /v2 API is not open yet. Your credentials are fine — /v2 is "
|
|
61
|
+
"limited to a small allowlist while it is finished, so qpd.v2 and "
|
|
62
|
+
"qpd.Historical will not work for you regardless of what you pass "
|
|
63
|
+
"them. The documented /v1 helpers (get_bars, get_ticks, get_universe, "
|
|
64
|
+
"get_coverage, symbology_resolve) are unaffected and carry the same "
|
|
65
|
+
"data. Contact QuantPad support if you need /v2 access."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
VERSION = "0.3.0"
|
|
28
69
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
29
70
|
TICK_SCHEMAS = (
|
|
30
71
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -32,6 +73,28 @@ TICK_SCHEMAS = (
|
|
|
32
73
|
)
|
|
33
74
|
ROLL_ADJUST_MODES = ("none", "back", "ratio")
|
|
34
75
|
|
|
76
|
+
#: ``(connect, read)`` for ``/v1/bars``, matching what ``/v1/ticks`` has
|
|
77
|
+
#: always used.
|
|
78
|
+
#:
|
|
79
|
+
#: The default 120s read budget is wrong for this route and was wrong in
|
|
80
|
+
#: the direction that hurts most. ``/v1/bars`` does not stream: the
|
|
81
|
+
#: server materialises the whole answer before the first byte, so the
|
|
82
|
+
#: read timeout is a cap on total server time rather than a stall
|
|
83
|
+
#: detector. Measured over three hours of production traffic, 24.6% of
|
|
84
|
+
#: bars requests took longer than 120s -- 37% of those from the notebook
|
|
85
|
+
#: sandbox, and 75% of every ``30s`` request -- against 2 of 1,924 tick
|
|
86
|
+
#: requests exceeding the 1800s ticks already allowed.
|
|
87
|
+
#:
|
|
88
|
+
#: Retrying made it worse rather than better. The server finishes an
|
|
89
|
+
#: abandoned request (it notices the client left on 1% of them), so a
|
|
90
|
+
#: timeout frees nothing and the three retries turn one slow call into
|
|
91
|
+
#: four concurrent copies of it, each holding one of the eight stream
|
|
92
|
+
#: slots a key is allowed. That is a self-inflicted 429.
|
|
93
|
+
#:
|
|
94
|
+
#: This is a floor under the symptom, not a fix: the tail beyond 1800s is
|
|
95
|
+
#: server latency and belongs in the engine, not in a client's patience.
|
|
96
|
+
BARS_TIMEOUT = (30, 1800)
|
|
97
|
+
|
|
35
98
|
|
|
36
99
|
class Client:
|
|
37
100
|
def __init__(
|
|
@@ -79,7 +142,7 @@ class Client:
|
|
|
79
142
|
pass
|
|
80
143
|
return (response.text or f"HTTP {response.status_code}").strip()
|
|
81
144
|
|
|
82
|
-
def _raise_for_status(self, response: requests.Response) -> None:
|
|
145
|
+
def _raise_for_status(self, response: requests.Response, path: str = "") -> None:
|
|
83
146
|
if response.ok:
|
|
84
147
|
return
|
|
85
148
|
message = self._message(response)
|
|
@@ -89,6 +152,8 @@ class Client:
|
|
|
89
152
|
except (KeyError, TypeError, ValueError):
|
|
90
153
|
pass
|
|
91
154
|
status = response.status_code
|
|
155
|
+
if (status, message) in _V2_GATE_REPLIES and path.startswith("/v2/"):
|
|
156
|
+
raise V2NotAvailableError(V2_NOT_AVAILABLE, status)
|
|
92
157
|
if status == 400:
|
|
93
158
|
raise ValidationError(message)
|
|
94
159
|
if status == 401:
|
|
@@ -114,7 +179,7 @@ class Client:
|
|
|
114
179
|
response = self.session.request(
|
|
115
180
|
method, f"{self.base_url}{path}", headers=headers, **kwargs
|
|
116
181
|
)
|
|
117
|
-
self._raise_for_status(response)
|
|
182
|
+
self._raise_for_status(response, path)
|
|
118
183
|
return response
|
|
119
184
|
except RateLimitError as exc:
|
|
120
185
|
if response is not None:
|
|
@@ -164,14 +229,18 @@ class Client:
|
|
|
164
229
|
"format": "arrow",
|
|
165
230
|
"roll_adjust": self._roll_adjust(symbol, roll_adjust),
|
|
166
231
|
}
|
|
167
|
-
response = self._request(
|
|
232
|
+
response = self._request(
|
|
233
|
+
"GET", "/v1/bars", params=params, timeout=BARS_TIMEOUT, stream=True
|
|
234
|
+
)
|
|
168
235
|
try:
|
|
169
236
|
table = ipc.open_stream(response.content).read_all()
|
|
170
237
|
frame = table.to_pandas()
|
|
171
238
|
except Exception:
|
|
172
239
|
response.close()
|
|
173
240
|
params["format"] = "csv"
|
|
174
|
-
fallback = self._request(
|
|
241
|
+
fallback = self._request(
|
|
242
|
+
"GET", "/v1/bars", params=params, timeout=BARS_TIMEOUT, stream=True
|
|
243
|
+
)
|
|
175
244
|
try:
|
|
176
245
|
frame = pd.read_csv(io.StringIO(fallback.text))
|
|
177
246
|
finally:
|
|
@@ -48,6 +48,30 @@ class NotFoundError(ValueError, QuantPadDataError):
|
|
|
48
48
|
pass
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class V2NotAvailableError(NotFoundError, PermissionDeniedError):
|
|
52
|
+
"""``/v2`` is gated and this caller is not through the gate.
|
|
53
|
+
|
|
54
|
+
Its own class because the two gates are otherwise indistinguishable
|
|
55
|
+
from real answers, and the 404 one is actively misleading: a caller
|
|
56
|
+
who wrapped ``get_range`` in ``except NotFoundError`` to mean "no
|
|
57
|
+
data for that symbol" would report exactly that, for a request the
|
|
58
|
+
service never looked at.
|
|
59
|
+
|
|
60
|
+
Inherits from **both** errors the gates would otherwise raise, so
|
|
61
|
+
existing ``except NotFoundError`` and ``except PermissionDeniedError``
|
|
62
|
+
clauses keep catching it and this stays a message change for anyone
|
|
63
|
+
who has already written against it.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
67
|
+
# NotFoundError comes first in the MRO and reaches ValueError's
|
|
68
|
+
# __init__, which does not take status_code — hence setting it
|
|
69
|
+
# here rather than delegating, the same reason the base class
|
|
70
|
+
# carries a class-level default.
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.status_code = status_code
|
|
73
|
+
|
|
74
|
+
|
|
51
75
|
class ValidationError(ValueError, QuantPadDataError):
|
|
52
76
|
pass
|
|
53
77
|
|
|
@@ -66,6 +66,44 @@ def test_bars_uses_api_key_and_preserves_aliases():
|
|
|
66
66
|
assert session.calls[0][2]["params"]["roll_adjust"] == "back"
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def test_bars_gets_the_same_read_budget_as_ticks():
|
|
70
|
+
"""The default 120s was a cap on total server time, not a stall
|
|
71
|
+
detector: ``/v1/bars`` materialises the whole answer before the first
|
|
72
|
+
byte, and 24.6% of production bars requests take longer than that.
|
|
73
|
+
Timing out did not even save the work -- the server finishes the
|
|
74
|
+
request regardless, so the three retries just ran four copies of it.
|
|
75
|
+
"""
|
|
76
|
+
from quantpad_data.client import BARS_TIMEOUT
|
|
77
|
+
|
|
78
|
+
table = pa.table({"t": [1_700_000_000_000], "c": [1.5]})
|
|
79
|
+
session = FakeSession([response(body=arrow_bytes(table))])
|
|
80
|
+
client = Client(api_key="secret", session=session, max_retries=0)
|
|
81
|
+
|
|
82
|
+
client.get_bars("GC.FUT", "30s", 1, 2)
|
|
83
|
+
|
|
84
|
+
connect, read = session.calls[0][2]["timeout"]
|
|
85
|
+
assert (connect, read) == BARS_TIMEOUT
|
|
86
|
+
assert read >= 1800, "a one-year 30s request does not fit in 120s"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_the_csv_fallback_keeps_the_long_read_budget():
|
|
90
|
+
"""It re-requests the same window, so inheriting the 120s default
|
|
91
|
+
would time out exactly where the Arrow attempt just did."""
|
|
92
|
+
from quantpad_data.client import BARS_TIMEOUT
|
|
93
|
+
|
|
94
|
+
table = pa.table({"t": [1_700_000_000_000], "c": [1.5]})
|
|
95
|
+
session = FakeSession(
|
|
96
|
+
[response(body=b"not arrow"), response(body=b"t,c\n1700000000000,1.5\n")]
|
|
97
|
+
)
|
|
98
|
+
client = Client(api_key="secret", session=session, max_retries=0)
|
|
99
|
+
|
|
100
|
+
client.get_bars("GC.FUT", "30s", 1, 2)
|
|
101
|
+
|
|
102
|
+
assert len(session.calls) == 2
|
|
103
|
+
assert session.calls[1][2]["timeout"] == BARS_TIMEOUT
|
|
104
|
+
assert session.calls[1][2]["params"]["format"] == "csv"
|
|
105
|
+
|
|
106
|
+
|
|
69
107
|
def test_rate_limit_exposes_retry_after():
|
|
70
108
|
session = FakeSession(
|
|
71
109
|
[response(429, b'{"detail":"rate limit"}', {"Retry-After": "7"})]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The ``/v2`` gates must be legible, and must not swallow real answers.
|
|
2
|
+
|
|
3
|
+
``/v2`` is shut to everyone but a two-account allowlist, and it says so
|
|
4
|
+
in two different ways: an API key is refused 403 "API keys cannot access
|
|
5
|
+
this endpoint", a notebook token 404 "Not Found". The second is the
|
|
6
|
+
dangerous one — it is indistinguishable from "that symbol has no data",
|
|
7
|
+
which is a thing callers legitimately wrap in ``except NotFoundError``.
|
|
8
|
+
|
|
9
|
+
What these tests mostly guard is the *other* direction. ``/v2`` returns
|
|
10
|
+
403 and 404 for real reasons too, and translating those into "the API is
|
|
11
|
+
not open" would replace a true answer with a false one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from quantpad_data.client import V2_NOT_AVAILABLE, Client
|
|
22
|
+
from quantpad_data.errors import (
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PermissionDeniedError,
|
|
25
|
+
QuantPadDataError,
|
|
26
|
+
V2NotAvailableError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _response(
|
|
31
|
+
status: int,
|
|
32
|
+
detail,
|
|
33
|
+
url: str = "https://example.test/v2/timeseries/get_range",
|
|
34
|
+
field: str = "error",
|
|
35
|
+
):
|
|
36
|
+
"""Default to ``error``, which is what production actually sends.
|
|
37
|
+
|
|
38
|
+
Confirmed by causing both gates against api.quantpad.ai on
|
|
39
|
+
2026-09-14: the app's handler renders ``{"error": ...}``, and only a
|
|
40
|
+
route that does not exist at all falls through to FastAPI's
|
|
41
|
+
``{"detail": ...}``. The client reads either, and both shapes are
|
|
42
|
+
exercised below so that stays true.
|
|
43
|
+
"""
|
|
44
|
+
response = requests.Response()
|
|
45
|
+
response.status_code = status
|
|
46
|
+
response.url = url
|
|
47
|
+
response._content = json.dumps({field: detail}).encode()
|
|
48
|
+
response.headers["Content-Type"] = "application/json"
|
|
49
|
+
return response
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _client() -> Client:
|
|
53
|
+
return Client(api_key="k", base_url="https://example.test")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestTheGateIsNamed:
|
|
57
|
+
def test_the_api_key_gate_is_not_reported_as_a_bare_permission_problem(self):
|
|
58
|
+
with pytest.raises(V2NotAvailableError) as caught:
|
|
59
|
+
_client()._raise_for_status(
|
|
60
|
+
_response(403, "API keys cannot access this endpoint"),
|
|
61
|
+
"/v2/timeseries/get_range",
|
|
62
|
+
)
|
|
63
|
+
assert caught.value.status_code == 403
|
|
64
|
+
assert "not open yet" in str(caught.value)
|
|
65
|
+
|
|
66
|
+
def test_the_notebook_gate_is_not_reported_as_a_missing_symbol(self):
|
|
67
|
+
"""The whole point. A 404 here means the service never looked."""
|
|
68
|
+
with pytest.raises(V2NotAvailableError) as caught:
|
|
69
|
+
_client()._raise_for_status(
|
|
70
|
+
_response(404, "Not Found"), "/v2/timeseries/get_range"
|
|
71
|
+
)
|
|
72
|
+
assert caught.value.status_code == 404
|
|
73
|
+
assert str(caught.value) == V2_NOT_AVAILABLE
|
|
74
|
+
|
|
75
|
+
def test_the_message_says_v1_still_works(self):
|
|
76
|
+
"""A caller who reads this should not conclude the SDK is broken."""
|
|
77
|
+
assert "get_bars" in V2_NOT_AVAILABLE
|
|
78
|
+
assert "not open yet" in V2_NOT_AVAILABLE
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("field", ["error", "detail"])
|
|
81
|
+
def test_both_body_shapes_are_recognised(self, field):
|
|
82
|
+
"""Production sends ``error``; FastAPI's own 404 sends ``detail``.
|
|
83
|
+
The client reads either, so the gate must not depend on which."""
|
|
84
|
+
with pytest.raises(V2NotAvailableError):
|
|
85
|
+
_client()._raise_for_status(
|
|
86
|
+
_response(404, "Not Found", field=field), "/v2/timeseries/get_range"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestExistingCatchesStillCatchIt:
|
|
91
|
+
"""The new class inherits from both gates' original errors on purpose.
|
|
92
|
+
|
|
93
|
+
Anyone who already wrote against either keeps working; this is a
|
|
94
|
+
message change for them, not a break.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize(
|
|
98
|
+
"status, detail",
|
|
99
|
+
[(403, "API keys cannot access this endpoint"), (404, "Not Found")],
|
|
100
|
+
)
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
"caught_as", [NotFoundError, PermissionDeniedError, QuantPadDataError]
|
|
103
|
+
)
|
|
104
|
+
def test_the_old_exception_types_still_match(self, status, detail, caught_as):
|
|
105
|
+
with pytest.raises(caught_as):
|
|
106
|
+
_client()._raise_for_status(
|
|
107
|
+
_response(status, detail), "/v2/timeseries/get_range"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestRealAnswersSurvive:
|
|
112
|
+
"""``/v2``'s other 403s and 404s are answers, not gates."""
|
|
113
|
+
|
|
114
|
+
def test_an_unknown_batch_job_is_still_a_missing_thing(self):
|
|
115
|
+
"""404 "No such batch job" is deliberate — it stops job-id
|
|
116
|
+
enumeration — and it is about the request, not about access."""
|
|
117
|
+
with pytest.raises(NotFoundError) as caught:
|
|
118
|
+
_client()._raise_for_status(
|
|
119
|
+
_response(404, "No such batch job", url="https://example.test/v2/batch/x/files"),
|
|
120
|
+
"/v2/batch/x/files",
|
|
121
|
+
)
|
|
122
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
123
|
+
assert "No such batch job" in str(caught.value)
|
|
124
|
+
|
|
125
|
+
def test_an_out_of_plan_window_is_still_a_permission_problem(self):
|
|
126
|
+
with pytest.raises(PermissionDeniedError) as caught:
|
|
127
|
+
_client()._raise_for_status(
|
|
128
|
+
_response(
|
|
129
|
+
403,
|
|
130
|
+
"That window is outside what this subscription carries "
|
|
131
|
+
"for this schema.",
|
|
132
|
+
),
|
|
133
|
+
"/v2/timeseries/get_range",
|
|
134
|
+
)
|
|
135
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
136
|
+
assert "subscription" in str(caught.value)
|
|
137
|
+
|
|
138
|
+
def test_a_v1_404_is_untouched(self):
|
|
139
|
+
"""The gate only exists on /v2. A /v1 miss is a real miss."""
|
|
140
|
+
with pytest.raises(NotFoundError) as caught:
|
|
141
|
+
_client()._raise_for_status(
|
|
142
|
+
_response(404, "Not Found", url="https://example.test/v1/bars"),
|
|
143
|
+
"/v1/bars",
|
|
144
|
+
)
|
|
145
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
146
|
+
|
|
147
|
+
def test_the_prefix_is_anchored_not_merely_contained(self):
|
|
148
|
+
"""No ``/v1`` route contains "v2" today, so this guards a contract
|
|
149
|
+
rather than a live bug — but it is the contract the whole design
|
|
150
|
+
rests on. A substring test would relabel a genuine miss on any
|
|
151
|
+
future path with "v2" in it as "the API is not open", which is
|
|
152
|
+
the same lie this class exists to prevent, told the other way
|
|
153
|
+
round. Futures codes alone make that plausible: ESV2 is a real
|
|
154
|
+
contract month.
|
|
155
|
+
"""
|
|
156
|
+
for path in ("/v1/coverage/ESV2", "/v1/v2meta", "/healthz/v2"):
|
|
157
|
+
with pytest.raises(NotFoundError) as caught:
|
|
158
|
+
_client()._raise_for_status(_response(404, "Not Found"), path)
|
|
159
|
+
assert not isinstance(caught.value, V2NotAvailableError), path
|
|
160
|
+
|
|
161
|
+
def test_the_path_is_what_decides_not_the_message_alone(self):
|
|
162
|
+
"""Same body, same status, different route — different meaning."""
|
|
163
|
+
gated = pytest.raises(V2NotAvailableError)
|
|
164
|
+
with gated:
|
|
165
|
+
_client()._raise_for_status(
|
|
166
|
+
_response(404, "Not Found"), "/v2/symbology/resolve"
|
|
167
|
+
)
|
|
168
|
+
with pytest.raises(NotFoundError) as plain:
|
|
169
|
+
_client()._raise_for_status(_response(404, "Not Found"), "/v1/coverage")
|
|
170
|
+
assert not isinstance(plain.value, V2NotAvailableError)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestItIsActuallyWiredUp:
|
|
174
|
+
"""Everything above calls the translator directly and passes the path
|
|
175
|
+
by hand, so all of it would still pass if ``_request`` stopped
|
|
176
|
+
handing the path over. These drive the real call instead.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
class _Session:
|
|
180
|
+
def __init__(self, response):
|
|
181
|
+
self.headers: dict[str, str] = {}
|
|
182
|
+
self._response = response
|
|
183
|
+
self.calls: list[tuple] = []
|
|
184
|
+
|
|
185
|
+
def request(self, method, url, **kwargs):
|
|
186
|
+
self.calls.append((method, url, kwargs))
|
|
187
|
+
return self._response
|
|
188
|
+
|
|
189
|
+
def test_a_real_v2_call_reports_the_gate(self):
|
|
190
|
+
session = self._Session(_response(404, "Not Found"))
|
|
191
|
+
client = Client(api_key="k", base_url="https://example.test", session=session, max_retries=0)
|
|
192
|
+
with pytest.raises(V2NotAvailableError):
|
|
193
|
+
client._request("POST", "/v2/timeseries/get_range", json={})
|
|
194
|
+
|
|
195
|
+
def test_a_real_v1_call_with_the_same_reply_does_not(self):
|
|
196
|
+
session = self._Session(_response(404, "Not Found"))
|
|
197
|
+
client = Client(api_key="k", base_url="https://example.test", session=session, max_retries=0)
|
|
198
|
+
with pytest.raises(NotFoundError) as caught:
|
|
199
|
+
client._request("GET", "/v1/coverage")
|
|
200
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestItSurvivesTheRoundTrip:
|
|
204
|
+
def test_status_code_is_readable_despite_the_valueerror_base(self):
|
|
205
|
+
"""NotFoundError reaches ValueError's __init__, which drops the
|
|
206
|
+
status. The subclass sets it explicitly; this is the regression
|
|
207
|
+
test for that being removed."""
|
|
208
|
+
error = V2NotAvailableError(V2_NOT_AVAILABLE, 404)
|
|
209
|
+
assert error.status_code == 404
|
|
210
|
+
|
|
211
|
+
def test_it_is_exported_at_the_root(self):
|
|
212
|
+
import quantpad_data
|
|
213
|
+
|
|
214
|
+
assert quantpad_data.V2NotAvailableError is V2NotAvailableError
|
|
215
|
+
assert "V2NotAvailableError" in quantpad_data.__all__
|
quantpad_data-0.2.0/.gitignore
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
"""QuantPad market-data SDK and notebook-compatible function API."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Iterator, Optional
|
|
6
|
-
|
|
7
|
-
import numpy as np
|
|
8
|
-
import pandas as pd
|
|
9
|
-
import pyarrow as pa
|
|
10
|
-
|
|
11
|
-
from .client import Client, ROLL_ADJUST_MODES, TICK_SCHEMAS, USER_AGENT, VERSION
|
|
12
|
-
from .errors import (
|
|
13
|
-
AuthenticationError,
|
|
14
|
-
NotFoundError,
|
|
15
|
-
PermissionDeniedError,
|
|
16
|
-
QuantPadDataError,
|
|
17
|
-
QuantPadDataNotReady,
|
|
18
|
-
QuotaExceededError,
|
|
19
|
-
RateLimitError,
|
|
20
|
-
UpstreamError,
|
|
21
|
-
ValidationError,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
__version__ = VERSION
|
|
25
|
-
QuantPadClient = Client
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _client(**kwargs) -> Client:
|
|
29
|
-
return Client(**kwargs)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def get_bars(symbol, timeframe, start_ms, end_ms, *, max_rows=None, as_df=True,
|
|
33
|
-
roll_adjust=None):
|
|
34
|
-
return _client().get_bars(
|
|
35
|
-
symbol, timeframe, start_ms, end_ms, max_rows=max_rows,
|
|
36
|
-
as_df=as_df, roll_adjust=roll_adjust,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_bars_with_retry(symbol, timeframe, start_ms, end_ms, *, max_rows=None,
|
|
41
|
-
retries=3, sleep_seconds=10, roll_adjust=None):
|
|
42
|
-
# Client retries use Retry-After and exponential jitter. ``sleep_seconds``
|
|
43
|
-
# remains accepted so existing notebooks do not break.
|
|
44
|
-
return Client(max_retries=max(0, int(retries) - 1)).get_bars(
|
|
45
|
-
symbol, timeframe, start_ms, end_ms, max_rows=max_rows,
|
|
46
|
-
roll_adjust=roll_adjust,
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def get_ticks(symbol, schema, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
51
|
-
return _client().get_ticks(
|
|
52
|
-
symbol, schema, start_ms, end_ms, columns=columns, chunksize=chunksize
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def get_trades(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
57
|
-
return get_ticks(
|
|
58
|
-
symbol, "trades", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def get_mbp1(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
63
|
-
return get_ticks(
|
|
64
|
-
symbol, "mbp-1", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def get_mbp10(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
69
|
-
return get_ticks(
|
|
70
|
-
symbol, "mbp-10", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def symbology_resolve(dataset=None, symbols=None, stype_in=None, stype_out=None,
|
|
75
|
-
start_date=None, end_date=None):
|
|
76
|
-
return _client().symbology_resolve(
|
|
77
|
-
dataset, symbols, stype_in, stype_out, start_date, end_date
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def get_universe(query: str, *, limit: int = 20, asset_class: str | None = None):
|
|
82
|
-
return _client().get_universe(query, limit=limit, asset_class=asset_class)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def get_coverage(symbol: str):
|
|
86
|
-
return _client().get_coverage(symbol)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _iter_rth_windows_ms(start_ms: int, end_ms: int):
|
|
90
|
-
try:
|
|
91
|
-
import exchange_calendars as ec
|
|
92
|
-
except ImportError as exc:
|
|
93
|
-
raise RuntimeError(
|
|
94
|
-
"RTH helpers require: pip install 'quantpad-data[rth]'"
|
|
95
|
-
) from exc
|
|
96
|
-
cal = ec.get_calendar("XNYS")
|
|
97
|
-
start_day = pd.Timestamp(start_ms, unit="ms", tz="UTC").tz_convert(
|
|
98
|
-
"America/New_York"
|
|
99
|
-
).date()
|
|
100
|
-
end_day = pd.Timestamp(end_ms - 1, unit="ms", tz="UTC").tz_convert(
|
|
101
|
-
"America/New_York"
|
|
102
|
-
).date()
|
|
103
|
-
for session in cal.sessions_in_range(pd.Timestamp(start_day), pd.Timestamp(end_day)):
|
|
104
|
-
open_ms = int(cal.session_open(session).timestamp() * 1000)
|
|
105
|
-
close_ms = int(cal.session_close(session).timestamp() * 1000)
|
|
106
|
-
left, right = max(open_ms, int(start_ms)), min(close_ms, int(end_ms))
|
|
107
|
-
if left < right:
|
|
108
|
-
yield left, right
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _stream_ticks_rth(symbol, schema, start_ms, end_ms, *, columns, chunk_rows):
|
|
112
|
-
for open_ms, close_ms in _iter_rth_windows_ms(start_ms, end_ms):
|
|
113
|
-
for frame in get_ticks(
|
|
114
|
-
symbol, schema, open_ms, close_ms,
|
|
115
|
-
columns=columns, chunksize=chunk_rows,
|
|
116
|
-
):
|
|
117
|
-
if "t" in frame:
|
|
118
|
-
frame = frame.loc[
|
|
119
|
-
(frame["t"] >= open_ms * 1_000_000)
|
|
120
|
-
& (frame["t"] < close_ms * 1_000_000)
|
|
121
|
-
]
|
|
122
|
-
if not frame.empty:
|
|
123
|
-
yield frame
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def get_trades_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
127
|
-
return _stream_ticks_rth(
|
|
128
|
-
symbol, "trades", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def get_mbp1_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
133
|
-
return _stream_ticks_rth(
|
|
134
|
-
symbol, "mbp-1", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def get_mbp10_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
139
|
-
return _stream_ticks_rth(
|
|
140
|
-
symbol, "mbp-10", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def get_spread_1m_rth(symbol, start_ms, end_ms, *, chunk_rows=250_000):
|
|
145
|
-
pieces = []
|
|
146
|
-
for frame in get_mbp1_rth(
|
|
147
|
-
symbol, start_ms, end_ms,
|
|
148
|
-
columns=["t", "bid_px", "ask_px"], chunk_rows=chunk_rows,
|
|
149
|
-
):
|
|
150
|
-
spread = frame["ask_px"] - frame["bid_px"]
|
|
151
|
-
valid = spread.notna() & (spread >= 0)
|
|
152
|
-
if valid.any():
|
|
153
|
-
index = pd.to_datetime(frame.loc[valid, "t"], unit="ns", utc=True)
|
|
154
|
-
pieces.append(pd.Series(spread.loc[valid].to_numpy(), index=index))
|
|
155
|
-
if not pieces:
|
|
156
|
-
return pd.DataFrame(
|
|
157
|
-
{"spread": pd.Series(dtype="float64")},
|
|
158
|
-
index=pd.DatetimeIndex([], name="t", tz="UTC"),
|
|
159
|
-
)
|
|
160
|
-
result = pd.concat(pieces).resample("1min").mean().to_frame("spread")
|
|
161
|
-
result.index.name = "t"
|
|
162
|
-
return result
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def iter_trades(symbol, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
166
|
-
return get_trades(
|
|
167
|
-
symbol, start_ms, end_ms, columns=columns, chunk_rows=chunksize
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def iter_mbp1(symbol, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
172
|
-
return get_mbp1(symbol, start_ms, end_ms, columns=columns, chunk_rows=chunksize)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def iter_l1_df_chunks(symbol, schema="trades", start_ms=0, end_ms=0,
|
|
176
|
-
chunksize=250_000, usecols: Optional[list[str]] = None,
|
|
177
|
-
time_col="ts_event", set_datetime_index=True, channel=None,
|
|
178
|
-
chunk_size=None):
|
|
179
|
-
return get_ticks(
|
|
180
|
-
symbol, channel or schema, start_ms, end_ms, columns=usecols,
|
|
181
|
-
chunksize=chunk_size or chunksize,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def iter_l1_trades_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
186
|
-
usecols=None):
|
|
187
|
-
return iter_trades(
|
|
188
|
-
symbol, start_ms, end_ms, columns=usecols, chunksize=chunksize
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def iter_l1_mbp1_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
193
|
-
usecols=None):
|
|
194
|
-
return iter_mbp1(symbol, start_ms, end_ms, columns=usecols, chunksize=chunksize)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
from . import lean # noqa: E402 (submodule: LEAN backtesting data staging)
|
|
198
|
-
from . import v2 # noqa: E402 (submodule: Databento-shaped explicit API)
|
|
199
|
-
|
|
200
|
-
# Exported at the root as well as on ``v2``: the sandbox module reaches it
|
|
201
|
-
# as ``qpd.Historical()``, and one spelling across both surfaces means
|
|
202
|
-
# notebook code moves between them unchanged.
|
|
203
|
-
Historical = v2.Historical
|
|
204
|
-
|
|
205
|
-
__all__ = [
|
|
206
|
-
"Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
|
|
207
|
-
"PermissionDeniedError", "RateLimitError", "QuotaExceededError",
|
|
208
|
-
"NotFoundError", "ValidationError", "UpstreamError", "TICK_SCHEMAS",
|
|
209
|
-
"ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
|
|
210
|
-
"get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
|
|
211
|
-
"get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
|
|
212
|
-
"get_coverage", "iter_trades", "iter_mbp1", "iter_l1_df_chunks",
|
|
213
|
-
"iter_l1_trades_df_chunks", "iter_l1_mbp1_df_chunks", "lean", "v2",
|
|
214
|
-
"Historical", "np", "pd", "pa",
|
|
215
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|