quantpad-data 0.2.1__tar.gz → 0.3.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.2.1 → quantpad_data-0.3.1}/.gitignore +5 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/PKG-INFO +9 -1
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/README.md +8 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/pyproject.toml +1 -1
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/quantpad_data/client.py +46 -3
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/quantpad_data/errors.py +24 -0
- quantpad_data-0.3.1/tests/test_message_encoding.py +85 -0
- quantpad_data-0.3.1/tests/test_v2_gate.py +215 -0
- quantpad_data-0.2.1/quantpad_data/__init__.py +0 -215
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/quantpad_data/v2.py +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/tests/test_client.py +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/tests/test_lean.py +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.1}/tests/test_v2.py +0 -0
- {quantpad_data-0.2.1 → quantpad_data-0.3.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.3.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
|
|
@@ -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.1"
|
|
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",
|
|
@@ -101,7 +142,7 @@ class Client:
|
|
|
101
142
|
pass
|
|
102
143
|
return (response.text or f"HTTP {response.status_code}").strip()
|
|
103
144
|
|
|
104
|
-
def _raise_for_status(self, response: requests.Response) -> None:
|
|
145
|
+
def _raise_for_status(self, response: requests.Response, path: str = "") -> None:
|
|
105
146
|
if response.ok:
|
|
106
147
|
return
|
|
107
148
|
message = self._message(response)
|
|
@@ -111,6 +152,8 @@ class Client:
|
|
|
111
152
|
except (KeyError, TypeError, ValueError):
|
|
112
153
|
pass
|
|
113
154
|
status = response.status_code
|
|
155
|
+
if (status, message) in _V2_GATE_REPLIES and path.startswith("/v2/"):
|
|
156
|
+
raise V2NotAvailableError(V2_NOT_AVAILABLE, status)
|
|
114
157
|
if status == 400:
|
|
115
158
|
raise ValidationError(message)
|
|
116
159
|
if status == 401:
|
|
@@ -136,7 +179,7 @@ class Client:
|
|
|
136
179
|
response = self.session.request(
|
|
137
180
|
method, f"{self.base_url}{path}", headers=headers, **kwargs
|
|
138
181
|
)
|
|
139
|
-
self._raise_for_status(response)
|
|
182
|
+
self._raise_for_status(response, path)
|
|
140
183
|
return response
|
|
141
184
|
except RateLimitError as exc:
|
|
142
185
|
if response is not None:
|
|
@@ -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
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Runtime strings stay ASCII, because they are read on real terminals.
|
|
2
|
+
|
|
3
|
+
Found by running the SDK against production and watching the em-dash in
|
|
4
|
+
``V2_NOT_AVAILABLE`` come back as a replacement character on a Windows
|
|
5
|
+
console. The message exists to be legible; a message that renders as
|
|
6
|
+
mojibake on a chunk of our users' terminals is not doing its job, and
|
|
7
|
+
the irony of it being the "here is what actually went wrong" string was
|
|
8
|
+
the tell.
|
|
9
|
+
|
|
10
|
+
Scoped to runtime string *literals*. Docstrings and comments are read in
|
|
11
|
+
an editor and keep their typography.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import pathlib
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
PACKAGE = pathlib.Path(__file__).resolve().parent.parent / "quantpad_data"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _runtime_strings(path: pathlib.Path):
|
|
25
|
+
"""Every string constant that is not a docstring, with its line."""
|
|
26
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
27
|
+
docstrings = set()
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(
|
|
30
|
+
node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
31
|
+
):
|
|
32
|
+
text = ast.get_docstring(node, clean=False)
|
|
33
|
+
if text:
|
|
34
|
+
docstrings.add(text)
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
37
|
+
if node.value not in docstrings:
|
|
38
|
+
yield node.lineno, node.value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
"module", sorted(PACKAGE.rglob("*.py")), ids=lambda p: p.name
|
|
43
|
+
)
|
|
44
|
+
def test_no_runtime_string_needs_a_utf8_terminal(module: pathlib.Path):
|
|
45
|
+
offenders = [
|
|
46
|
+
(line, sorted({c for c in value if ord(c) > 127}), value[:60])
|
|
47
|
+
for line, value in _runtime_strings(module)
|
|
48
|
+
if any(ord(c) > 127 for c in value)
|
|
49
|
+
]
|
|
50
|
+
assert not offenders, (
|
|
51
|
+
f"{module.name} has runtime strings that need a UTF-8 terminal: "
|
|
52
|
+
f"{offenders}. Use ASCII - these are printed to consoles we do "
|
|
53
|
+
f"not control."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_check_can_actually_see_a_violation():
|
|
58
|
+
"""A guard that passes on everything is not a guard.
|
|
59
|
+
|
|
60
|
+
The trap this repo keeps hitting: a diagnostic built to confirm a
|
|
61
|
+
change will confirm it. So prove the detector fires.
|
|
62
|
+
"""
|
|
63
|
+
source = 'X = "an em dash \u2014 here"\n'
|
|
64
|
+
tree = ast.parse(source)
|
|
65
|
+
found = [
|
|
66
|
+
n.value
|
|
67
|
+
for n in ast.walk(tree)
|
|
68
|
+
if isinstance(n, ast.Constant) and isinstance(n.value, str)
|
|
69
|
+
]
|
|
70
|
+
assert any(any(ord(c) > 127 for c in v) for v in found)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_prose_is_deliberately_exempt():
|
|
74
|
+
"""The scoping is the whole design, so pin it both ways.
|
|
75
|
+
|
|
76
|
+
``client.py`` is full of em-dashes in comments and docstrings and
|
|
77
|
+
must stay passing. If the check ever widens to the raw file, that is
|
|
78
|
+
a false positive on prose nobody prints.
|
|
79
|
+
"""
|
|
80
|
+
source = (PACKAGE / "client.py").read_text(encoding="utf-8")
|
|
81
|
+
assert "\u2014" in source, "expected this module's prose to use em-dashes"
|
|
82
|
+
|
|
83
|
+
runtime = [v for _, v in _runtime_strings(PACKAGE / "client.py")]
|
|
84
|
+
assert runtime, "parser returned nothing - it would pass vacuously"
|
|
85
|
+
assert not [v for v in runtime if any(ord(c) > 127 for c in v)]
|
|
@@ -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__
|
|
@@ -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
|
|
File without changes
|