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.
@@ -14,3 +14,8 @@ venv/
14
14
  # obviously not part of the service; there are usually a lot of them
15
15
  # mid-investigation and none of them should ever be committed.
16
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.2.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- VERSION = "0.2.1"
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
- ]