quantpad-data 0.5.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/PKG-INFO +6 -1
  2. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/README.md +5 -0
  3. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/pyproject.toml +1 -1
  4. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/client.py +54 -1
  5. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/v2.py +45 -9
  6. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_client.py +50 -0
  7. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_v2.py +165 -0
  8. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/.gitignore +0 -0
  9. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/__init__.py +0 -0
  10. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/cache.py +0 -0
  11. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/errors.py +0 -0
  12. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/lean.py +0 -0
  13. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/memory.py +0 -0
  14. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/py.typed +0 -0
  15. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/quantpad_data/rth.py +0 -0
  16. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/conftest.py +0 -0
  17. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_lean.py +0 -0
  18. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_message_encoding.py +0 -0
  19. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_packaging.py +0 -0
  20. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_rth.py +0 -0
  21. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_v2_cache.py +0 -0
  22. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_v2_gate.py +0 -0
  23. {quantpad_data-0.5.1 → quantpad_data-0.6.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Official Python client for the QuantPad market-data API
5
5
  Project-URL: Documentation, https://api.quantpad.ai/external/docs
6
6
  Author: QuantPad
@@ -73,6 +73,11 @@ coverage = qpd.get_coverage("AAPL")
73
73
  `QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
74
74
  and `max_retries=`. The default
75
75
  client honors `Retry-After` and uses exponential backoff for transient failures.
76
+ A read timeout is the exception and is never retried: the budget was already
77
+ spent in full, so the request is probably still running on the server, and
78
+ repeating it adds load rather than replacing it. Connection failures and
79
+ connect timeouts are still retried, because failing to reach the service
80
+ costs the service nothing.
76
81
  QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
77
82
 
78
83
  Bars preserve the notebook helper's OHLCV aliases and smart futures
@@ -51,6 +51,11 @@ coverage = qpd.get_coverage("AAPL")
51
51
  `QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
52
52
  and `max_retries=`. The default
53
53
  client honors `Retry-After` and uses exponential backoff for transient failures.
54
+ A read timeout is the exception and is never retried: the budget was already
55
+ spent in full, so the request is probably still running on the server, and
56
+ repeating it adds load rather than replacing it. Connection failures and
57
+ connect timeouts are still retried, because failing to reach the service
58
+ costs the service nothing.
54
59
  QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
55
60
 
56
61
  Bars preserve the notebook helper's OHLCV aliases and smart futures
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.5.1"
7
+ version = "0.6.0"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
84
84
  "QuantPad support about /v2 access."
85
85
  )
86
86
 
87
- VERSION = "0.5.1"
87
+ VERSION = "0.6.0"
88
88
  USER_AGENT = f"quantpad-data-python/{VERSION}"
89
89
  TICK_SCHEMAS = (
90
90
  "trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
@@ -114,6 +114,34 @@ ROLL_ADJUST_MODES = ("none", "back", "ratio")
114
114
  #: server latency and belongs in the engine, not in a client's patience.
115
115
  BARS_TIMEOUT = (30, 1800)
116
116
 
117
+ #: (connect, read) budget for a ``/v2`` route that has to fetch.
118
+ #:
119
+ #: ``/v2`` inherited the bare ``self.timeout`` -- 120 seconds, a SCALAR --
120
+ #: which is the mistake ``BARS_TIMEOUT`` above exists to correct, made
121
+ #: again on the surface meant to replace bars.
122
+ #:
123
+ #: It lands harder here. A scalar timeout is a cap on TIME TO FIRST
124
+ #: BYTE, and ``/v2`` sends no first byte until its fetch is finished:
125
+ #: ``get_range`` plans, prices, fetches every missing day from upstream
126
+ #: and only then primes the first Arrow batch. So the number is not a
127
+ #: patience setting, it is a guess about how long upstream takes --
128
+ #: made once, for every schema and every width.
129
+ #:
130
+ #: Measured against that guess: a cold fourteen-day crude-options
131
+ #: ``mbp-1`` window spent its entire budget inside the fetch and failed
132
+ #: work that was going fine. And since the service learned to stop
133
+ #: serving a caller who has left, the guess is now what *ends* the work
134
+ #: rather than merely what stops waiting for it.
135
+ #:
136
+ #: 1800 is what ``/v1/ticks`` and ``/v1/bars`` already allow for the same
137
+ #: kind of work, so this is ``/v2`` catching up with ``/v1`` rather than
138
+ #: a new indulgence. It has to be this large only because nothing on
139
+ #: this socket yet says "still working": a read budget can be short when
140
+ #: silence means death and must be long when silence is normal. Once
141
+ #: the service reports progress while it fetches, this becomes a real
142
+ #: stall detector and comes down to a couple of minutes.
143
+ V2_DATA_TIMEOUT = (10, 1800)
144
+
117
145
 
118
146
  class Client:
119
147
  def __init__(
@@ -236,6 +264,31 @@ class Client:
236
264
  if attempt >= self.max_retries:
237
265
  raise
238
266
  delay = exc.retry_after
267
+ except requests.ReadTimeout:
268
+ # The one thing never retried, and the exception proves the
269
+ # rule rather than breaking it: everything else in these
270
+ # clauses is cheap to repeat, and this is the opposite.
271
+ #
272
+ # A read timeout means the whole budget above was spent
273
+ # without the server going quiet by choice, so the work is
274
+ # very likely still running. Repeating the request does
275
+ # not replace that work, it adds to it -- the comment on
276
+ # BARS_TIMEOUT measured where that leads: three retries
277
+ # turning one slow call into four concurrent copies, each
278
+ # holding one of the eight stream slots a key is allowed,
279
+ # which is a self-inflicted 429. Worse now than when that
280
+ # was written, because the service cancels for a caller
281
+ # who has left, so each retry also destroys the progress
282
+ # the one before it had made.
283
+ #
284
+ # A caller is better served by the truth after one budget
285
+ # than by the same answer after four. ConnectTimeout is
286
+ # subclassed from ConnectionError as well and so is still
287
+ # caught below, which is right: failing to reach the
288
+ # service at all costs the service nothing.
289
+ if response is not None:
290
+ response.close()
291
+ raise
239
292
  except (requests.ConnectionError, requests.Timeout, UpstreamError) as exc:
240
293
  if response is not None:
241
294
  response.close()
@@ -37,6 +37,7 @@ from __future__ import annotations
37
37
  import io
38
38
  import json
39
39
  import os
40
+ import tempfile
40
41
  from datetime import date, timedelta
41
42
  from typing import Any, Iterator, Sequence
42
43
 
@@ -47,7 +48,7 @@ import pyarrow.parquet as pq
47
48
  import requests
48
49
 
49
50
  from . import cache, memory, rth
50
- from .client import Client
51
+ from .client import V2_DATA_TIMEOUT, Client
51
52
  from .errors import QuantPadDataError, ResponseTooLargeError, ValidationError
52
53
 
53
54
  #: Above this, ``get_range`` stops buffering a single response and reads
@@ -56,6 +57,22 @@ from .errors import QuantPadDataError, ResponseTooLargeError, ValidationError
56
57
  #: in a notebook degrades into a slower read rather than a failure.
57
58
  _BULK_FALLBACK_STATUS = 413
58
59
 
60
+ #: (connect, read) budget for a presigned read from object storage.
61
+ #:
62
+ #: Deliberately far shorter than :data:`V2_DATA_TIMEOUT`. There is no
63
+ #: work behind an object download -- no planning, no upstream fetch, no
64
+ #: transcode -- so nothing here can legitimately take minutes to begin,
65
+ #: and silence really is a stall. This is the one place a read budget
66
+ #: does the job it is named for; handing it the service's budget would
67
+ #: let a dead transfer hang a notebook for half an hour.
68
+ STORAGE_TIMEOUT = (10, 120)
69
+
70
+ #: Bytes of a partition held in memory before the download spills to
71
+ #: disk. Most partitions are far under this; a depth day is not, and
72
+ #: that is exactly the case where holding the compressed body and the
73
+ #: decoded table at full size at once is worth avoiding.
74
+ STORAGE_SPOOL_BYTES = 32 * 1024 * 1024
75
+
59
76
 
60
77
  def _symbols(value: str | Sequence[str]) -> list[str]:
61
78
  return [value] if isinstance(value, str) else [str(v) for v in value]
@@ -271,7 +288,8 @@ class Timeseries(_Namespace):
271
288
 
272
289
  try:
273
290
  response = self._client._request(
274
- "POST", "/v2/timeseries/get_range", json=body
291
+ "POST", "/v2/timeseries/get_range", json=body,
292
+ timeout=V2_DATA_TIMEOUT,
275
293
  )
276
294
  except ResponseTooLargeError as exc:
277
295
  # Bulk answers OUR ceiling and does not answer the caller's own
@@ -331,6 +349,7 @@ class Timeseries(_Namespace):
331
349
  start=start, end=end, stype_in=stype_in,
332
350
  stype_out=stype_out, columns=columns,
333
351
  ),
352
+ timeout=V2_DATA_TIMEOUT,
334
353
  ).json()
335
354
 
336
355
  def iter_range(self, **kwargs) -> Iterator[pd.DataFrame]:
@@ -505,6 +524,7 @@ class Timeseries(_Namespace):
505
524
  payload = self._client._request(
506
525
  "POST", "/v2/timeseries/manifest",
507
526
  json={**manifest_body, "start": start, "end": end},
527
+ timeout=V2_DATA_TIMEOUT,
508
528
  ).json()
509
529
  except ResponseTooLargeError as exc:
510
530
  if exc.reason != "hydration_limit":
@@ -793,7 +813,7 @@ class Batch(_Namespace):
793
813
  storage — the job is a one-time cost, not a per-read one.
794
814
  """
795
815
  return self._client._request(
796
- "POST", f"/v2/batch/{job_id}/hydrate"
816
+ "POST", f"/v2/batch/{job_id}/hydrate", timeout=V2_DATA_TIMEOUT,
797
817
  ).json()
798
818
 
799
819
 
@@ -1040,12 +1060,28 @@ def _read_parquet_url(url: str, columns: Sequence[str] | None) -> pd.DataFrame:
1040
1060
  point of the manifest path is that these bytes never traverse the
1041
1061
  data service.
1042
1062
  """
1043
- response = requests.get(url, timeout=300)
1044
- response.raise_for_status()
1045
- table = pq.read_table(
1046
- io.BytesIO(response.content),
1047
- columns=list(columns) if columns else None,
1048
- )
1063
+ # ``with``, because ``stream=True`` leaves the body unread until it
1064
+ # is: a ``raise_for_status`` on the next line would otherwise abandon
1065
+ # the connection holding one, and a manifest walks hundreds of these.
1066
+ with requests.get(
1067
+ url, timeout=STORAGE_TIMEOUT, stream=True
1068
+ ) as response:
1069
+ response.raise_for_status()
1070
+ # Spooled rather than ``io.BytesIO(response.content)`` so the
1071
+ # compressed bytes and the decoded table are not both resident
1072
+ # at their full size: past the threshold the download goes to
1073
+ # disk and only the table is in memory. Streaming without this
1074
+ # buys nothing -- ``.content`` materialises the whole body
1075
+ # anyway, just later.
1076
+ with tempfile.SpooledTemporaryFile(
1077
+ max_size=STORAGE_SPOOL_BYTES
1078
+ ) as spool:
1079
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
1080
+ spool.write(chunk)
1081
+ spool.seek(0)
1082
+ table = pq.read_table(
1083
+ spool, columns=list(columns) if columns else None
1084
+ )
1049
1085
  return table.to_pandas()
1050
1086
 
1051
1087
 
@@ -104,6 +104,56 @@ def test_the_csv_fallback_keeps_the_long_read_budget():
104
104
  assert session.calls[1][2]["params"]["format"] == "csv"
105
105
 
106
106
 
107
+ class _RaisingSession:
108
+ """A session that raises the same exception on every attempt."""
109
+
110
+ def __init__(self, exc):
111
+ self.headers = {}
112
+ self.exc = exc
113
+ self.calls = 0
114
+
115
+ def request(self, method, url, **kwargs):
116
+ self.calls += 1
117
+ raise self.exc
118
+
119
+
120
+ def test_a_read_timeout_is_not_retried():
121
+ """Retrying a read timeout adds work rather than replacing it.
122
+
123
+ The budget was spent in full without the server choosing to go quiet,
124
+ so the request is very likely still running. ``BARS_TIMEOUT``'s own
125
+ comment measured where repeating it leads: three retries turn one
126
+ slow call into four concurrent copies, each holding one of the eight
127
+ stream slots a key is allowed, which is a self-inflicted 429. Worse
128
+ since the service cancels for a caller who left, because each retry
129
+ now also destroys the progress the one before it made.
130
+ """
131
+ session = _RaisingSession(requests.ReadTimeout("read timed out"))
132
+ client = Client(api_key="secret", session=session, max_retries=3)
133
+
134
+ with pytest.raises(requests.ReadTimeout):
135
+ client.get_coverage("AAPL")
136
+
137
+ assert session.calls == 1, "a read timeout must cost exactly one attempt"
138
+
139
+
140
+ def test_a_connect_timeout_is_still_retried():
141
+ """The other half, and the reason this is a rule rather than a mood.
142
+
143
+ Failing to reach the service at all costs the service nothing, so it
144
+ is cheap to repeat -- which is exactly what a read timeout is not.
145
+ ConnectTimeout subclasses Timeout, so a rule written on Timeout alone
146
+ would have stopped retrying the one case that should be.
147
+ """
148
+ session = _RaisingSession(requests.ConnectTimeout("connect timed out"))
149
+ client = Client(api_key="secret", session=session, max_retries=2)
150
+
151
+ with pytest.raises(requests.ConnectTimeout):
152
+ client.get_coverage("AAPL")
153
+
154
+ assert session.calls == 3, "connect failures are cheap and still retried"
155
+
156
+
107
157
  def test_rate_limit_exposes_retry_after():
108
158
  session = FakeSession(
109
159
  [response(429, b'{"detail":"rate limit"}', {"Retry-After": "7"})]
@@ -121,6 +121,171 @@ class TestNothingIsInferred:
121
121
  assert session.calls == []
122
122
 
123
123
 
124
+ class TestWhatAV2CallIsAllowedToWaitFor:
125
+ """A read budget on /v2 is a guess about upstream, not about patience.
126
+
127
+ /v2 sends no first byte until its fetch is finished -- get_range
128
+ plans, prices, fetches every missing day and only then primes the
129
+ first Arrow batch -- so a scalar timeout is a cap on time to FIRST
130
+ BYTE. Inheriting the client's 120s default put that cap below the
131
+ normal cost of a cold window, and a cold fourteen-day crude-options
132
+ mbp-1 request spent its whole budget inside the fetch.
133
+ """
134
+
135
+ def _budget(self, kwargs):
136
+ return kwargs["timeout"]
137
+
138
+ def test_get_range_is_given_the_long_read_budget(self):
139
+ from quantpad_data.client import V2_DATA_TIMEOUT
140
+
141
+ api, session = client_with([response(200, arrow_bytes(bars_table()))])
142
+ api.timeseries.get_range(
143
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="ohlcv-1m",
144
+ start="2026-01-05", end="2026-01-06",
145
+ )
146
+
147
+ connect, read = self._budget(session.calls[0][2])
148
+ assert (connect, read) == V2_DATA_TIMEOUT
149
+ assert read >= 1800, "a cold window does not reliably fetch in 120s"
150
+
151
+ def test_the_manifest_is_given_it_too(self):
152
+ """The manifest hydrates inside the request, so it is the *more*
153
+ exposed of the two rather than the less: the whole of a cold
154
+ month's fetch happens before it answers."""
155
+ from quantpad_data.client import V2_DATA_TIMEOUT
156
+
157
+ api, session = client_with([json_response({"files": [], "gaps": []})])
158
+ api.timeseries.manifest(
159
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
160
+ start="2026-01-05", end="2026-02-05",
161
+ )
162
+
163
+ assert self._budget(session.calls[0][2]) == V2_DATA_TIMEOUT
164
+
165
+ def test_the_budget_is_a_pair_not_a_number(self):
166
+ """Connect and read are different questions and sharing one
167
+ answer is what made this wrong in both directions at once: 120s
168
+ is far too long to spend on a TCP handshake and far too short for
169
+ an upstream fetch."""
170
+ from quantpad_data.client import V2_DATA_TIMEOUT
171
+
172
+ connect, read = V2_DATA_TIMEOUT
173
+ assert connect <= 30, "a handshake that slow is a dead host"
174
+ assert read > connect * 10
175
+
176
+ def test_a_storage_read_keeps_a_short_budget(self, monkeypatch):
177
+ """Presigned partitions are a plain object download with no work
178
+ behind them, so silence there really is a stall -- the one case a
179
+ read budget is supposed to detect. Giving it get_range's budget
180
+ would make a dead transfer hang a notebook for half an hour."""
181
+ seen = {}
182
+
183
+ def fake_get(url, **kwargs):
184
+ seen.update(kwargs)
185
+ return response(200, _parquet_bytes(bars_table()))
186
+
187
+ monkeypatch.setattr(requests, "get", fake_get)
188
+ from quantpad_data import v2 as v2_module
189
+
190
+ v2_module._read_parquet_url("https://storage.test/day.parquet", None)
191
+
192
+ connect, read = seen["timeout"]
193
+ assert connect <= 30 and read <= 300
194
+
195
+ def test_a_storage_read_consumes_the_body_a_chunk_at_a_time(
196
+ self, monkeypatch
197
+ ):
198
+ """``stream=True`` alone buys nothing.
199
+
200
+ Reading ``.content`` behind it still materialises the whole
201
+ object before the parquet is decoded, so peak memory stays the
202
+ HTTP buffer plus the decoded frame -- the pair this was meant to
203
+ break up. The body has to actually be consumed in pieces.
204
+ """
205
+ spy = _StreamSpy(200, _parquet_bytes(bars_table()))
206
+ monkeypatch.setattr(requests, "get", lambda url, **kw: spy)
207
+ from quantpad_data import v2 as v2_module
208
+
209
+ v2_module._read_parquet_url("https://storage.test/day.parquet", None)
210
+
211
+ assert spy.streamed
212
+
213
+ def test_a_refused_storage_read_still_closes_the_connection(
214
+ self, monkeypatch
215
+ ):
216
+ """With ``stream=True`` the body is unread until someone reads
217
+ it, so a ``raise_for_status`` that escapes leaves a connection
218
+ holding one. A manifest walk makes hundreds of these calls, so a
219
+ leak per failure exhausts the pool rather than being untidy.
220
+ """
221
+ spy = _StreamSpy(403, b"expired")
222
+ monkeypatch.setattr(requests, "get", lambda url, **kw: spy)
223
+ from quantpad_data import v2 as v2_module
224
+
225
+ with pytest.raises(requests.HTTPError):
226
+ v2_module._read_parquet_url(
227
+ "https://storage.test/day.parquet", None
228
+ )
229
+
230
+ assert spy.closed
231
+
232
+ def test_a_storage_read_returns_the_rows_it_downloaded(self, monkeypatch):
233
+ """Asserted on the data rather than the mechanism: a chunked
234
+ download reassembled wrongly would still stream and still
235
+ close."""
236
+ spy = _StreamSpy(200, _parquet_bytes(bars_table()))
237
+ monkeypatch.setattr(requests, "get", lambda url, **kw: spy)
238
+ from quantpad_data import v2 as v2_module
239
+
240
+ frame = v2_module._read_parquet_url(
241
+ "https://storage.test/day.parquet", None
242
+ )
243
+
244
+ assert list(frame["close"]) == [100.0, 101.0]
245
+
246
+
247
+ class _StreamSpy:
248
+ """A response that records how its body was taken and when it closed.
249
+
250
+ Both are invisible from outside a real ``requests.Response``, and
251
+ both are the behaviour under test: ``.content`` and a chunked read
252
+ return the same bytes, and a leaked connection returns the right
253
+ answer right up until the pool runs out.
254
+ """
255
+
256
+ def __init__(self, status, body):
257
+ self.status_code = status
258
+ self.content = body
259
+ self.headers = {}
260
+ self.streamed = False
261
+ self.closed = False
262
+
263
+ def raise_for_status(self):
264
+ if self.status_code >= 400:
265
+ raise requests.HTTPError(f"HTTP {self.status_code}")
266
+
267
+ def iter_content(self, chunk_size=1):
268
+ self.streamed = True
269
+ for start in range(0, len(self.content), chunk_size):
270
+ yield self.content[start:start + chunk_size]
271
+
272
+ def close(self):
273
+ self.closed = True
274
+
275
+ def __enter__(self):
276
+ return self
277
+
278
+ def __exit__(self, *exc):
279
+ self.close()
280
+ return False
281
+
282
+
283
+ def _parquet_bytes(table):
284
+ sink = io.BytesIO()
285
+ pq.write_table(table, sink)
286
+ return sink.getvalue()
287
+
288
+
124
289
  class TestTheResolvedAddressIsReported:
125
290
  def test_echo_headers_land_on_the_frame(self):
126
291
  """A caller has to be able to confirm which contract answered,
File without changes