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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.3.2
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. When that would be too large, it falls
135
- back automatically to reading parquet straight from object storage — the bytes
136
- never traverse the data service. Force either behaviour with
137
- `mode="interactive"` or `mode="bulk"`, or stream day by day:
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. When that would be too large, it falls
114
- back automatically to reading parquet straight from object storage — the bytes
115
- never traverse the data service. Force either behaviour with
116
- `mode="interactive"` or `mode="bulk"`, or stream day by day:
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.3.2"
7
+ version = "0.4.1"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.3.2"
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 _message(response: requests.Response) -> str:
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
- pass
167
- return (response.text or f"HTTP {response.status_code}").strip()
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._message(response)
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
- def __init__(self, message: str, status_code: int | None = None):
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)