quantpad-data 0.3.2__tar.gz → 0.4.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.3.2 → quantpad_data-0.4.1}/PKG-INFO +22 -5
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/README.md +21 -4
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/pyproject.toml +1 -1
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/quantpad_data/client.py +30 -8
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/quantpad_data/errors.py +58 -1
- quantpad_data-0.4.1/quantpad_data/memory.py +150 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/quantpad_data/v2.py +323 -35
- quantpad_data-0.4.1/tests/test_v2.py +1437 -0
- quantpad_data-0.3.2/tests/test_v2.py +0 -719
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/.gitignore +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/tests/test_client.py +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/tests/test_lean.py +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.1}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.3.2 → quantpad_data-0.4.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.4.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
|
|
@@ -131,10 +131,22 @@ covering a different period than you asked for.
|
|
|
131
131
|
|
|
132
132
|
### Large ranges
|
|
133
133
|
|
|
134
|
-
`get_range` buffers a single response
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
`get_range` buffers a single response, so it tells the service up front how much
|
|
135
|
+
this process can hold. Two different things can then be too large, and they are
|
|
136
|
+
answered differently:
|
|
137
|
+
|
|
138
|
+
- **Too large for the service to assemble in one response.** `get_range` falls
|
|
139
|
+
back automatically to reading parquet straight from object storage — the bytes
|
|
140
|
+
never traverse the data service.
|
|
141
|
+
- **Too large for this process to hold.** You get a `ResponseTooLargeError`
|
|
142
|
+
naming the estimated size, because no delivery mode makes a frame fit in
|
|
143
|
+
memory it does not fit in. Stream it with `iter_range` instead, or pass
|
|
144
|
+
`max_decoded_bytes=` if the memory really is available.
|
|
145
|
+
|
|
146
|
+
The budget is derived from the memory visible to the process (a container's
|
|
147
|
+
limit, if there is one). Override it per call with `max_decoded_bytes=`, or
|
|
148
|
+
globally with the `QUANTPAD_MAX_DECODED_BYTES` environment variable. Force a
|
|
149
|
+
delivery path with `mode="interactive"` or `mode="bulk"`, or stream day by day:
|
|
138
150
|
|
|
139
151
|
```python
|
|
140
152
|
for day in client.timeseries.iter_range(
|
|
@@ -144,6 +156,11 @@ for day in client.timeseries.iter_range(
|
|
|
144
156
|
process(day)
|
|
145
157
|
```
|
|
146
158
|
|
|
159
|
+
`iter_range` holds one day at a time rather than the whole window, and splits
|
|
160
|
+
the window itself when a single request would have to fetch too much uncached
|
|
161
|
+
data at once. On a cold window the first frame can take a while to arrive, since
|
|
162
|
+
the days behind it are fetched before it is handed over.
|
|
163
|
+
|
|
147
164
|
For ranges too large even for that, submit a batch job, then hydrate it into
|
|
148
165
|
the cache so ordinary `get_range` calls serve it from storage:
|
|
149
166
|
|
|
@@ -110,10 +110,22 @@ covering a different period than you asked for.
|
|
|
110
110
|
|
|
111
111
|
### Large ranges
|
|
112
112
|
|
|
113
|
-
`get_range` buffers a single response
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
`get_range` buffers a single response, so it tells the service up front how much
|
|
114
|
+
this process can hold. Two different things can then be too large, and they are
|
|
115
|
+
answered differently:
|
|
116
|
+
|
|
117
|
+
- **Too large for the service to assemble in one response.** `get_range` falls
|
|
118
|
+
back automatically to reading parquet straight from object storage — the bytes
|
|
119
|
+
never traverse the data service.
|
|
120
|
+
- **Too large for this process to hold.** You get a `ResponseTooLargeError`
|
|
121
|
+
naming the estimated size, because no delivery mode makes a frame fit in
|
|
122
|
+
memory it does not fit in. Stream it with `iter_range` instead, or pass
|
|
123
|
+
`max_decoded_bytes=` if the memory really is available.
|
|
124
|
+
|
|
125
|
+
The budget is derived from the memory visible to the process (a container's
|
|
126
|
+
limit, if there is one). Override it per call with `max_decoded_bytes=`, or
|
|
127
|
+
globally with the `QUANTPAD_MAX_DECODED_BYTES` environment variable. Force a
|
|
128
|
+
delivery path with `mode="interactive"` or `mode="bulk"`, or stream day by day:
|
|
117
129
|
|
|
118
130
|
```python
|
|
119
131
|
for day in client.timeseries.iter_range(
|
|
@@ -123,6 +135,11 @@ for day in client.timeseries.iter_range(
|
|
|
123
135
|
process(day)
|
|
124
136
|
```
|
|
125
137
|
|
|
138
|
+
`iter_range` holds one day at a time rather than the whole window, and splits
|
|
139
|
+
the window itself when a single request would have to fetch too much uncached
|
|
140
|
+
data at once. On a cold window the first frame can take a while to arrive, since
|
|
141
|
+
the days behind it are fetched before it is handed over.
|
|
142
|
+
|
|
126
143
|
For ranges too large even for that, submit a batch job, then hydrate it into
|
|
127
144
|
the cache so ordinary `get_range` calls serve it from storage:
|
|
128
145
|
|
|
@@ -20,6 +20,7 @@ from .errors import (
|
|
|
20
20
|
QuantPadDataError,
|
|
21
21
|
QuotaExceededError,
|
|
22
22
|
RateLimitError,
|
|
23
|
+
ResponseTooLargeError,
|
|
23
24
|
UpstreamError,
|
|
24
25
|
V2NotAvailableError,
|
|
25
26
|
ValidationError,
|
|
@@ -89,7 +90,7 @@ V2_NOT_AVAILABLE = (
|
|
|
89
90
|
"QuantPad support about /v2 access."
|
|
90
91
|
)
|
|
91
92
|
|
|
92
|
-
VERSION = "0.
|
|
93
|
+
VERSION = "0.4.1"
|
|
93
94
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
94
95
|
TICK_SCHEMAS = (
|
|
95
96
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -157,19 +158,35 @@ class Client:
|
|
|
157
158
|
)
|
|
158
159
|
|
|
159
160
|
@staticmethod
|
|
160
|
-
def
|
|
161
|
+
def _envelope(response: requests.Response) -> tuple[str, dict | None]:
|
|
162
|
+
"""The prose and the structured body of a refusal, separately.
|
|
163
|
+
|
|
164
|
+
Some refusals answer with an object rather than a string -- a size
|
|
165
|
+
estimate, a breadth count, which of two limits was breached -- and
|
|
166
|
+
stringifying the whole thing put a Python dict repr in front of the
|
|
167
|
+
user where the service had written a sentence. So the prose is
|
|
168
|
+
unwrapped from ``message`` when there is one, and the object is
|
|
169
|
+
kept beside it for the callers that need to branch on it.
|
|
170
|
+
"""
|
|
161
171
|
try:
|
|
162
172
|
body = response.json()
|
|
163
|
-
if isinstance(body, dict):
|
|
164
|
-
return str(body.get("error") or body.get("detail") or body)
|
|
165
173
|
except ValueError:
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
return (response.text or f"HTTP {response.status_code}").strip(), None
|
|
175
|
+
if not isinstance(body, dict):
|
|
176
|
+
return str(body), None
|
|
177
|
+
error = body.get("error") or body.get("detail") or body
|
|
178
|
+
if isinstance(error, dict):
|
|
179
|
+
return str(error.get("message") or error), error
|
|
180
|
+
return str(error), None
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def _message(cls, response: requests.Response) -> str:
|
|
184
|
+
return cls._envelope(response)[0]
|
|
168
185
|
|
|
169
186
|
def _raise_for_status(self, response: requests.Response, path: str = "") -> None:
|
|
170
187
|
if response.ok:
|
|
171
188
|
return
|
|
172
|
-
message = self.
|
|
189
|
+
message, detail = self._envelope(response)
|
|
173
190
|
retry_after: float | None = None
|
|
174
191
|
try:
|
|
175
192
|
retry_after = float(response.headers["Retry-After"])
|
|
@@ -194,12 +211,17 @@ class Client:
|
|
|
194
211
|
raise PermissionDeniedError(message, status)
|
|
195
212
|
if status == 404:
|
|
196
213
|
raise NotFoundError(message)
|
|
214
|
+
if status == 413:
|
|
215
|
+
# Typed, because this is the one refusal a caller has to act on
|
|
216
|
+
# differently depending on WHY, and the reason is in the body
|
|
217
|
+
# rather than the status. See ResponseTooLargeError.
|
|
218
|
+
raise ResponseTooLargeError(message, status, detail)
|
|
197
219
|
if status == 429:
|
|
198
220
|
cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
|
|
199
221
|
raise cls(message, retry_after, status)
|
|
200
222
|
if status >= 500:
|
|
201
223
|
raise UpstreamError(message, status)
|
|
202
|
-
raise QuantPadDataError(message, status)
|
|
224
|
+
raise QuantPadDataError(message, status, detail)
|
|
203
225
|
|
|
204
226
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
205
227
|
kwargs.setdefault("timeout", self.timeout)
|
|
@@ -16,9 +16,22 @@ class QuantPadDataError(RuntimeError):
|
|
|
16
16
|
#: run this __init__.
|
|
17
17
|
status_code: int | None = None
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
#: The structured body, when the service sent one. Some refusals carry
|
|
20
|
+
#: machine-readable fields beside their prose -- a size estimate, or
|
|
21
|
+
#: which of two limits was breached -- and a client that has to match
|
|
22
|
+
#: on message text to find them is a client that breaks when the text
|
|
23
|
+
#: is improved.
|
|
24
|
+
detail: dict | None = None
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
status_code: int | None = None,
|
|
30
|
+
detail: dict | None = None,
|
|
31
|
+
):
|
|
20
32
|
super().__init__(message)
|
|
21
33
|
self.status_code = status_code
|
|
34
|
+
self.detail = detail
|
|
22
35
|
|
|
23
36
|
|
|
24
37
|
class AuthenticationError(QuantPadDataError):
|
|
@@ -72,6 +85,50 @@ class V2NotAvailableError(NotFoundError, PermissionDeniedError):
|
|
|
72
85
|
self.status_code = status_code
|
|
73
86
|
|
|
74
87
|
|
|
88
|
+
class ResponseTooLargeError(QuantPadDataError):
|
|
89
|
+
"""The answer will not fit somewhere, and the two somewheres differ.
|
|
90
|
+
|
|
91
|
+
``reason`` says which:
|
|
92
|
+
|
|
93
|
+
``"service_limit"``
|
|
94
|
+
Above a ceiling this service enforces to protect itself. Bulk
|
|
95
|
+
delivery genuinely answers this, which is why ``mode="auto"``
|
|
96
|
+
switches to it rather than failing.
|
|
97
|
+
|
|
98
|
+
``"client_budget"``
|
|
99
|
+
Above what this process said it could hold. Bulk delivery does
|
|
100
|
+
NOT answer this -- it ends in one ``concat`` and returns a frame
|
|
101
|
+
just as large, holding every day's frame at once to build it -- so
|
|
102
|
+
falling back would be a second route to the same MemoryError.
|
|
103
|
+
``iter_range`` is the answer, because it never holds more than one
|
|
104
|
+
partition.
|
|
105
|
+
|
|
106
|
+
A subclass of :class:`QuantPadDataError` and raised for the same 413 as
|
|
107
|
+
before, so ``except QuantPadDataError`` and any code branching on
|
|
108
|
+
``status_code == 413`` keeps working.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
message: str,
|
|
114
|
+
status_code: int | None = None,
|
|
115
|
+
detail: dict | None = None,
|
|
116
|
+
):
|
|
117
|
+
super().__init__(message, status_code, detail)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def reason(self) -> str | None:
|
|
121
|
+
return (self.detail or {}).get("reason")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def estimated_bytes(self) -> int | None:
|
|
125
|
+
return ((self.detail or {}).get("estimate") or {}).get("bytes")
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def estimated_rows(self) -> int | None:
|
|
129
|
+
return ((self.detail or {}).get("estimate") or {}).get("rows")
|
|
130
|
+
|
|
131
|
+
|
|
75
132
|
class ValidationError(ValueError, QuantPadDataError):
|
|
76
133
|
pass
|
|
77
134
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""How much of this answer this process can afford to hold.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS
|
|
4
|
+
``get_range`` returns one DataFrame, so it holds the whole answer. The
|
|
5
|
+
service's own ceilings do not protect that: the materializing path caps at
|
|
6
|
+
2 GiB and the streaming path at 64 GiB, and both numbers exist to keep the
|
|
7
|
+
SERVICE alive. A notebook with 4 GB is not protected by either, and a month of
|
|
8
|
+
liquid-equity depth prices at about 17 GB -- so it was served, buffered, and
|
|
9
|
+
killed the kernel with no error to read. That is the shape of the "all
|
|
10
|
+
projects hang, no error" report.
|
|
11
|
+
|
|
12
|
+
So the caller declares what it can hold and the service refuses above it. Only
|
|
13
|
+
the caller can know the number, and only the service can price the request
|
|
14
|
+
before fetching it, which is why it takes both.
|
|
15
|
+
|
|
16
|
+
THE UNITS ARE NOT THE SAME, AND THAT IS THE WHOLE DIFFICULTY
|
|
17
|
+
The service prices in DBN bytes: record count times record width, which is
|
|
18
|
+
Databento's own billing arithmetic. This process pays for the compressed wire
|
|
19
|
+
bytes, an Arrow table decoded from them, and a pandas frame copied out of that
|
|
20
|
+
table -- partly all three at once. Measured with
|
|
21
|
+
scripts/measure_client_memory.py against production, peak RSS per priced byte
|
|
22
|
+
was:
|
|
23
|
+
|
|
24
|
+
equity mbp-10, 1 day 693 MB priced 1.7 GB peak 2.49x
|
|
25
|
+
equity mbp-1, 1 day 69 MB priced 276 MB peak 4.02x
|
|
26
|
+
OPRA trades, 1 day 73 MB priced 370 MB peak 5.05x
|
|
27
|
+
|
|
28
|
+
The ratio is LARGER for narrow records, which is the opposite of the intuition:
|
|
29
|
+
DBN packs a trade into 48 bytes while pandas gives every column at least 8, so
|
|
30
|
+
a narrow record inflates more than a wide one. Hence a single factor taken
|
|
31
|
+
from the worst case rather than a per-schema table.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
#: Peak process bytes per byte the service prices. Worst measured was 5.05x;
|
|
39
|
+
#: rounded up because three schemas are not the whole catalogue and the two
|
|
40
|
+
#: errors are not symmetric -- too high refuses a request that would have
|
|
41
|
+
#: fitted, which under ``mode="auto"`` costs a slower path, while too low
|
|
42
|
+
#: returns to killing the process with no message.
|
|
43
|
+
PEAK_PER_PRICED_BYTE = 6.0
|
|
44
|
+
|
|
45
|
+
#: cgroup v1 spells "no limit" as a huge number rather than a word, and the
|
|
46
|
+
#: exact sentinel varies with page size, so anything absurd is read as absent.
|
|
47
|
+
_NO_LIMIT = 1 << 53
|
|
48
|
+
|
|
49
|
+
#: Overrides the detected figure. For an operator who has to correct this
|
|
50
|
+
#: from outside the notebook, which is the only lever available in a sandbox.
|
|
51
|
+
BUDGET_ENV = "QUANTPAD_MAX_DECODED_BYTES"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_int(path: str) -> int | None:
|
|
55
|
+
try:
|
|
56
|
+
with open(path) as handle:
|
|
57
|
+
return int(handle.read().strip())
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cgroup_free() -> int | None:
|
|
63
|
+
"""Headroom under this container's memory limit, if it has one.
|
|
64
|
+
|
|
65
|
+
Consulted BEFORE /proc/meminfo and allowed to win, because meminfo
|
|
66
|
+
reports the HOST's memory: in a sandbox on a large machine that is wrong
|
|
67
|
+
by an order of magnitude and wrong in the generous direction, which is
|
|
68
|
+
the direction that kills the process.
|
|
69
|
+
"""
|
|
70
|
+
for limit_path, usage_path in (
|
|
71
|
+
("/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory.current"),
|
|
72
|
+
(
|
|
73
|
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
74
|
+
"/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
|
75
|
+
),
|
|
76
|
+
):
|
|
77
|
+
limit = _read_int(limit_path)
|
|
78
|
+
# v2 writes the literal string "max", which _read_int reports as
|
|
79
|
+
# None; v1 writes a sentinel. Both mean unlimited.
|
|
80
|
+
if limit is None or limit >= _NO_LIMIT:
|
|
81
|
+
continue
|
|
82
|
+
return max(limit - (_read_int(usage_path) or 0), 0)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _meminfo_available() -> int | None:
|
|
87
|
+
"""MemAvailable, the kernel's own estimate of what can be allocated.
|
|
88
|
+
|
|
89
|
+
Preferred over MemFree, which excludes reclaimable cache and so
|
|
90
|
+
understates by gigabytes on any machine that has been up a while.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
with open("/proc/meminfo") as handle:
|
|
94
|
+
for line in handle:
|
|
95
|
+
if line.startswith("MemAvailable:"):
|
|
96
|
+
return int(line.split()[1]) * 1024
|
|
97
|
+
except (OSError, ValueError, IndexError):
|
|
98
|
+
pass
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def available_bytes() -> int | None:
|
|
103
|
+
"""What this process can still allocate, or None if it cannot be told.
|
|
104
|
+
|
|
105
|
+
None matters as much as a number: it means declare nothing and leave the
|
|
106
|
+
service's own ceilings in charge, which is the behaviour before this
|
|
107
|
+
existed. Guessing instead would refuse real requests on platforms where
|
|
108
|
+
the guess is wrong, and the platform this cannot read is somebody's
|
|
109
|
+
laptop rather than the sandbox this exists for.
|
|
110
|
+
"""
|
|
111
|
+
candidates = [
|
|
112
|
+
value
|
|
113
|
+
for value in (_cgroup_free(), _meminfo_available())
|
|
114
|
+
if value is not None
|
|
115
|
+
]
|
|
116
|
+
return min(candidates) if candidates else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def declared_budget(mode: str) -> int | None:
|
|
120
|
+
"""The figure to send as ``max_decoded_bytes``, in the service's units.
|
|
121
|
+
|
|
122
|
+
TWO NUMBERS, BECAUSE THE COST OF BEING WRONG DIFFERS BY MODE.
|
|
123
|
+
|
|
124
|
+
Under ``"auto"`` a refusal is cheap: the caller asked for a frame and can
|
|
125
|
+
be told to iterate instead, before anything is fetched. So it declares
|
|
126
|
+
the conservative figure -- free memory divided by the measured expansion
|
|
127
|
+
-- and errs towards refusing something that would have fitted.
|
|
128
|
+
|
|
129
|
+
Under ``"interactive"`` the caller has insisted on one buffered response
|
|
130
|
+
and there is nowhere to degrade to, so a refusal is a hard failure. It
|
|
131
|
+
therefore declares free memory UNDIVIDED, which refuses only what cannot
|
|
132
|
+
possibly fit: peak is larger than the priced figure in every case ever
|
|
133
|
+
measured (2.49x at the lowest), so a request priced above all available
|
|
134
|
+
memory is a certain MemoryError rather than a risk of one. Nothing that
|
|
135
|
+
works today starts failing.
|
|
136
|
+
"""
|
|
137
|
+
override = os.getenv(BUDGET_ENV, "").strip()
|
|
138
|
+
if override:
|
|
139
|
+
try:
|
|
140
|
+
parsed = int(override)
|
|
141
|
+
except ValueError:
|
|
142
|
+
parsed = 0
|
|
143
|
+
return parsed if parsed > 0 else None
|
|
144
|
+
|
|
145
|
+
free = available_bytes()
|
|
146
|
+
if free is None:
|
|
147
|
+
return None
|
|
148
|
+
if mode == "interactive":
|
|
149
|
+
return free
|
|
150
|
+
return int(free / PEAK_PER_PRICED_BYTE)
|