quantpad-data 0.5.1__tar.gz → 0.7.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.
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/PKG-INFO +6 -1
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/README.md +5 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/pyproject.toml +1 -1
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/client.py +54 -1
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/v2.py +253 -25
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_client.py +50 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_v2.py +691 -60
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/.gitignore +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/__init__.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/memory.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/conftest.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_lean.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_packaging.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_rth.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.0}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.5.1 → quantpad_data-0.7.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.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
|
|
|
84
84
|
"QuantPad support about /v2 access."
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
VERSION = "0.
|
|
87
|
+
VERSION = "0.7.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,42 @@ 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
|
+
|
|
76
|
+
#: Widest window of missing days :meth:`Timeseries.iter_range` fetches
|
|
77
|
+
#: in one request, once it has ramped up to it.
|
|
78
|
+
#:
|
|
79
|
+
#: A cadence, not a capacity. The service's own ceilings decide what
|
|
80
|
+
#: fits and refuse what does not, and :func:`_split_window` already
|
|
81
|
+
#: halves a window that gets refused, so nothing here is protecting
|
|
82
|
+
#: memory. What it buys is the caller seeing frames while the fetch is
|
|
83
|
+
#: still going, which one request for the whole window cannot give
|
|
84
|
+
#: however fast it is: measured on a cold month of niche-equity
|
|
85
|
+
#: ``mbp-10``, 129.5 of the 132.4 seconds elapsed before the first
|
|
86
|
+
#: frame, because one manifest was hydrating nineteen days inside the
|
|
87
|
+
#: request. That is indistinguishable from a hang, and it is the shape
|
|
88
|
+
#: of the "all projects hang, no error" report this exists to answer.
|
|
89
|
+
#:
|
|
90
|
+
#: Five caps a ramp that starts at one day (see :func:`_runs`), so a
|
|
91
|
+
#: cold month is a handful of requests rather than one or nineteen, and
|
|
92
|
+
#: the first of them is small. A warm window never reaches this at
|
|
93
|
+
#: all -- it has no gaps, so it stays the single manifest it always was.
|
|
94
|
+
HYDRATE_CHUNK_DAYS = 5
|
|
95
|
+
|
|
59
96
|
|
|
60
97
|
def _symbols(value: str | Sequence[str]) -> list[str]:
|
|
61
98
|
return [value] if isinstance(value, str) else [str(v) for v in value]
|
|
@@ -130,6 +167,66 @@ def _split_window(start: str, end: str) -> tuple[tuple[str, str], ...] | None:
|
|
|
130
167
|
return ((start, middle), (middle, end))
|
|
131
168
|
|
|
132
169
|
|
|
170
|
+
def _identity(item: dict) -> tuple:
|
|
171
|
+
"""What makes two manifest entries the same stored partition.
|
|
172
|
+
|
|
173
|
+
The object key, because it is what the service stores the partition
|
|
174
|
+
under and is therefore stable from one manifest to the next. The
|
|
175
|
+
obvious alternative, ``url``, is a freshly signed URL on every call
|
|
176
|
+
and would make every entry unique -- which is to say it would look
|
|
177
|
+
like it worked and deduplicate nothing.
|
|
178
|
+
|
|
179
|
+
Day and instrument ride along so that a service which omits ``key``
|
|
180
|
+
still distinguishes one instrument-day from another, rather than
|
|
181
|
+
collapsing a whole window onto a single ``None``.
|
|
182
|
+
"""
|
|
183
|
+
return (item.get("key"), item["day"], item["instrument_id"])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _runs(days: Sequence[str], size: int) -> Iterator[tuple[str, str]]:
|
|
187
|
+
"""Windows covering ``days``, small at first and then up to ``size``.
|
|
188
|
+
|
|
189
|
+
Consecutive days are grouped, because a run of missing days is one
|
|
190
|
+
fetch rather than several: the days between two gaps are already
|
|
191
|
+
cached and asking for them again would re-fetch what the caller
|
|
192
|
+
has. Groups are then cut into windows, because a run can be a
|
|
193
|
+
whole month and that is the silence being removed.
|
|
194
|
+
|
|
195
|
+
**The first window is one day, and each is twice the last up to**
|
|
196
|
+
``size``. A flat cut makes the first frame wait for a full chunk,
|
|
197
|
+
which measured 29.0s on a cold 8-day window of equity depth -- an
|
|
198
|
+
improvement on the 58.0s it replaced and still long enough to read
|
|
199
|
+
as nothing happening. Ramping puts the first frame after a single
|
|
200
|
+
day, and by the time the caller is watching output arrive the
|
|
201
|
+
windows are full width, so the round trips are spent where they buy
|
|
202
|
+
reassurance rather than spread over the whole fetch.
|
|
203
|
+
|
|
204
|
+
Yields half-open ``(start, end)`` pairs, ``end`` being the midnight
|
|
205
|
+
after the window's last day. Day-aligned for :func:`_split_window`'s
|
|
206
|
+
reason -- a partition is a whole day, so a boundary inside one would
|
|
207
|
+
put that day in both windows and return its rows twice.
|
|
208
|
+
"""
|
|
209
|
+
ordered = sorted({date.fromisoformat(day) for day in days})
|
|
210
|
+
groups: list[list[date]] = []
|
|
211
|
+
for day in ordered:
|
|
212
|
+
if groups and day == groups[-1][-1] + timedelta(days=1):
|
|
213
|
+
groups[-1].append(day)
|
|
214
|
+
else:
|
|
215
|
+
groups.append([day])
|
|
216
|
+
|
|
217
|
+
# Carried ACROSS groups, not reset per group: the ramp is about how
|
|
218
|
+
# long the caller has been waiting, and a second group is reached
|
|
219
|
+
# only after the first has already produced frames.
|
|
220
|
+
width = 1
|
|
221
|
+
for group in groups:
|
|
222
|
+
index = 0
|
|
223
|
+
while index < len(group):
|
|
224
|
+
piece = group[index : index + width]
|
|
225
|
+
index += width
|
|
226
|
+
width = min(width * 2, size)
|
|
227
|
+
yield piece[0].isoformat(), (piece[-1] + timedelta(days=1)).isoformat()
|
|
228
|
+
|
|
229
|
+
|
|
133
230
|
def _table_from_arrow(payload: bytes) -> pa.Table:
|
|
134
231
|
with ipc.open_stream(io.BytesIO(payload)) as reader:
|
|
135
232
|
return reader.read_all()
|
|
@@ -271,7 +368,8 @@ class Timeseries(_Namespace):
|
|
|
271
368
|
|
|
272
369
|
try:
|
|
273
370
|
response = self._client._request(
|
|
274
|
-
"POST", "/v2/timeseries/get_range", json=body
|
|
371
|
+
"POST", "/v2/timeseries/get_range", json=body,
|
|
372
|
+
timeout=V2_DATA_TIMEOUT,
|
|
275
373
|
)
|
|
276
374
|
except ResponseTooLargeError as exc:
|
|
277
375
|
# Bulk answers OUR ceiling and does not answer the caller's own
|
|
@@ -331,6 +429,7 @@ class Timeseries(_Namespace):
|
|
|
331
429
|
start=start, end=end, stype_in=stype_in,
|
|
332
430
|
stype_out=stype_out, columns=columns,
|
|
333
431
|
),
|
|
432
|
+
timeout=V2_DATA_TIMEOUT,
|
|
334
433
|
).json()
|
|
335
434
|
|
|
336
435
|
def iter_range(self, **kwargs) -> Iterator[pd.DataFrame]:
|
|
@@ -339,8 +438,13 @@ class Timeseries(_Namespace):
|
|
|
339
438
|
For ranges that do not fit in memory. Each yielded frame is one
|
|
340
439
|
(day, instrument) partition, in chronological order.
|
|
341
440
|
|
|
342
|
-
|
|
343
|
-
|
|
441
|
+
**Streams the fetch, not only the answer.** The first call asks
|
|
442
|
+
the manifest not to hydrate, so it returns in about a second
|
|
443
|
+
however cold the window is and says which days it does not have.
|
|
444
|
+
Cached days are then yielded straight away and the missing ones
|
|
445
|
+
fetched in runs of :data:`HYDRATE_CHUNK_DAYS` as the iteration
|
|
446
|
+
reaches them. A warm window is still one round trip; a cold one
|
|
447
|
+
produces frames throughout instead of after a single silence.
|
|
344
448
|
"""
|
|
345
449
|
if kwargs.get("roll_adjust", "none") != "none" or kwargs.get("resample"):
|
|
346
450
|
raise ValidationError(
|
|
@@ -355,18 +459,17 @@ class Timeseries(_Namespace):
|
|
|
355
459
|
# day, instrument, projection), so the whole-window body is the right
|
|
356
460
|
# thing to hand every piece.
|
|
357
461
|
manifest_body = _manifest_body(columns=columns, **kwargs)
|
|
358
|
-
for payload in self.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
yield frame
|
|
462
|
+
for payload, item in self._partitions(manifest_body):
|
|
463
|
+
frame = _labelled(
|
|
464
|
+
_partition(manifest_body, item, columns), item, columns
|
|
465
|
+
)
|
|
466
|
+
frame.attrs["quantpad"] = {
|
|
467
|
+
**_manifest_attrs(payload),
|
|
468
|
+
"day": item["day"],
|
|
469
|
+
"instrument_id": item["instrument_id"],
|
|
470
|
+
"symbol": item.get("symbol"),
|
|
471
|
+
}
|
|
472
|
+
yield frame
|
|
370
473
|
|
|
371
474
|
def iter_range_rth(self, **kwargs) -> Iterator[pd.DataFrame]:
|
|
372
475
|
""":meth:`iter_range`, restricted to XNYS regular trading hours.
|
|
@@ -480,7 +583,103 @@ class Timeseries(_Namespace):
|
|
|
480
583
|
exc.detail,
|
|
481
584
|
)
|
|
482
585
|
|
|
483
|
-
def
|
|
586
|
+
def _partitions(self, manifest_body: dict) -> Iterator[tuple[dict, dict]]:
|
|
587
|
+
"""``(manifest, file)`` pairs in day order, fetching as it goes.
|
|
588
|
+
|
|
589
|
+
The window is surveyed once without hydrating -- cheap whatever
|
|
590
|
+
the cache holds, measured at 0.9s against 29.6s for the same
|
|
591
|
+
cold window fetched -- and the survey says which days are
|
|
592
|
+
missing. Days already present are handed over immediately;
|
|
593
|
+
missing ones are fetched only when the walk reaches them, so a
|
|
594
|
+
caller whose window is half warm sees frames before any fetch
|
|
595
|
+
starts at all.
|
|
596
|
+
|
|
597
|
+
**Chronological across the join**, which is the constraint that
|
|
598
|
+
shapes this: yielding every cached day and then every fetched
|
|
599
|
+
one would be simpler and would reorder the window, and
|
|
600
|
+
``iter_range`` promises day order. So the two are interleaved
|
|
601
|
+
by day rather than concatenated.
|
|
602
|
+
|
|
603
|
+
**A calendar day is not the unit of cache state**, which is the
|
|
604
|
+
other constraint and much the less obvious one. A manifest
|
|
605
|
+
speaks in instrument-days, so asking for two symbols whose
|
|
606
|
+
histories were fetched at different times puts one date in
|
|
607
|
+
``files`` for one symbol and in ``gaps`` for the other. The
|
|
608
|
+
fetch for that date then answers with BOTH instruments -- it is
|
|
609
|
+
given a window, not a list of holes -- so the already-cached one
|
|
610
|
+
arrives a second time, and a caller concatenating the frames
|
|
611
|
+
would silently double those rows. Hence :func:`_identity`: the
|
|
612
|
+
day decides WHEN a partition is handed over, the partition
|
|
613
|
+
itself decides whether it already has been.
|
|
614
|
+
|
|
615
|
+
Degrades rather than breaks against a service that predates the
|
|
616
|
+
flag: an older manifest ignores ``hydrate`` (the request models
|
|
617
|
+
do not forbid unknown fields, so it is dropped rather than
|
|
618
|
+
rejected), fetches anyway and answers with no gaps, which this
|
|
619
|
+
reads as a fully cached window and streams directly. Slower,
|
|
620
|
+
never wrong -- and it keeps the splitting it would otherwise
|
|
621
|
+
have lost, since the survey goes through :meth:`_manifests`
|
|
622
|
+
rather than asking once. Against a service that honours the
|
|
623
|
+
flag that costs nothing: nothing is fetched, so there is no
|
|
624
|
+
hydration ceiling to breach and the splitter loops once.
|
|
625
|
+
"""
|
|
626
|
+
surveyed = list(self._manifests(manifest_body, hydrate=False))
|
|
627
|
+
|
|
628
|
+
# No early return for the all-cached case, though one reads
|
|
629
|
+
# naturally here and was written first. The walk below already
|
|
630
|
+
# handles it in one round trip -- there is nothing to fetch, so
|
|
631
|
+
# nothing is fetched -- and it additionally puts the days in
|
|
632
|
+
# order, where handing back ``survey["files"]`` would inherit
|
|
633
|
+
# whatever order the service happened to list them in. A
|
|
634
|
+
# mutation battery is what established the two were equivalent:
|
|
635
|
+
# the shortcut turned out to be the weaker of the two.
|
|
636
|
+
ready: dict[str, list[tuple[dict, dict]]] = {}
|
|
637
|
+
missing: set[str] = set()
|
|
638
|
+
for payload in surveyed:
|
|
639
|
+
for item in payload["files"]:
|
|
640
|
+
ready.setdefault(item["day"], []).append((payload, item))
|
|
641
|
+
for gap in payload.get("gaps") or ():
|
|
642
|
+
missing.add(gap["day"])
|
|
643
|
+
|
|
644
|
+
seen: set[tuple] = set()
|
|
645
|
+
|
|
646
|
+
def fresh(pairs) -> Iterator[tuple[dict, dict]]:
|
|
647
|
+
for payload, item in pairs:
|
|
648
|
+
identity = _identity(item)
|
|
649
|
+
if identity not in seen:
|
|
650
|
+
seen.add(identity)
|
|
651
|
+
yield payload, item
|
|
652
|
+
|
|
653
|
+
# One cursor over the union of both sets, so a fetched day lands
|
|
654
|
+
# between the cached days that surround it.
|
|
655
|
+
fetched: dict[str, list[tuple[dict, dict]]] = {}
|
|
656
|
+
pending = list(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
|
|
657
|
+
for day in sorted(set(ready) | missing):
|
|
658
|
+
while pending and pending[0][0] <= day:
|
|
659
|
+
start, end = pending.pop(0)
|
|
660
|
+
for payload in self._manifests(
|
|
661
|
+
{**manifest_body, "start": start, "end": end}
|
|
662
|
+
):
|
|
663
|
+
for item in payload["files"]:
|
|
664
|
+
fetched.setdefault(item["day"], []).append((payload, item))
|
|
665
|
+
yield from fresh(ready.get(day, ()))
|
|
666
|
+
# A missing day that came back with nothing -- a holiday, or
|
|
667
|
+
# a genuinely quiet session -- simply has no entry here, and
|
|
668
|
+
# is skipped exactly as an ``empty`` day always was.
|
|
669
|
+
yield from fresh(fetched.pop(day, ()))
|
|
670
|
+
|
|
671
|
+
# Anything a fetch returned for a day the survey did not list.
|
|
672
|
+
# It should be empty, and it is drained rather than asserted
|
|
673
|
+
# about, because the two calls resolve symbols independently and
|
|
674
|
+
# the failure of the cursor to reach a day would otherwise drop
|
|
675
|
+
# its rows in silence -- the one outcome worth ruling out by
|
|
676
|
+
# construction.
|
|
677
|
+
for day in sorted(fetched):
|
|
678
|
+
yield from fresh(fetched[day])
|
|
679
|
+
|
|
680
|
+
def _manifests(
|
|
681
|
+
self, manifest_body: dict, *, hydrate: bool = True
|
|
682
|
+
) -> Iterator[dict]:
|
|
484
683
|
"""Manifests covering the window, splitting it when the service says to.
|
|
485
684
|
|
|
486
685
|
The manifest hydrates its missing days synchronously, under its own
|
|
@@ -497,6 +696,13 @@ class Timeseries(_Namespace):
|
|
|
497
696
|
a property of the data rather than of the calendar -- a month of
|
|
498
697
|
options trades and a month of equity depth differ by orders of
|
|
499
698
|
magnitude -- and because the warm case must stay one round trip.
|
|
699
|
+
|
|
700
|
+
``hydrate`` rides through the split rather than being applied by
|
|
701
|
+
the caller afterwards, so a survey that has to be halved stays a
|
|
702
|
+
survey. Only a service predating the flag can put it in that
|
|
703
|
+
position -- one that honours it fetches nothing and so cannot
|
|
704
|
+
refuse on a hydration ceiling -- but that is exactly the service
|
|
705
|
+
whose refusal this exists to absorb.
|
|
500
706
|
"""
|
|
501
707
|
pending = [(manifest_body["start"], manifest_body["end"])]
|
|
502
708
|
while pending:
|
|
@@ -504,7 +710,13 @@ class Timeseries(_Namespace):
|
|
|
504
710
|
try:
|
|
505
711
|
payload = self._client._request(
|
|
506
712
|
"POST", "/v2/timeseries/manifest",
|
|
507
|
-
json={
|
|
713
|
+
json={
|
|
714
|
+
**manifest_body,
|
|
715
|
+
"start": start,
|
|
716
|
+
"end": end,
|
|
717
|
+
"hydrate": hydrate,
|
|
718
|
+
},
|
|
719
|
+
timeout=V2_DATA_TIMEOUT,
|
|
508
720
|
).json()
|
|
509
721
|
except ResponseTooLargeError as exc:
|
|
510
722
|
if exc.reason != "hydration_limit":
|
|
@@ -793,7 +1005,7 @@ class Batch(_Namespace):
|
|
|
793
1005
|
storage — the job is a one-time cost, not a per-read one.
|
|
794
1006
|
"""
|
|
795
1007
|
return self._client._request(
|
|
796
|
-
"POST", f"/v2/batch/{job_id}/hydrate"
|
|
1008
|
+
"POST", f"/v2/batch/{job_id}/hydrate", timeout=V2_DATA_TIMEOUT,
|
|
797
1009
|
).json()
|
|
798
1010
|
|
|
799
1011
|
|
|
@@ -1040,12 +1252,28 @@ def _read_parquet_url(url: str, columns: Sequence[str] | None) -> pd.DataFrame:
|
|
|
1040
1252
|
point of the manifest path is that these bytes never traverse the
|
|
1041
1253
|
data service.
|
|
1042
1254
|
"""
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
)
|
|
1255
|
+
# ``with``, because ``stream=True`` leaves the body unread until it
|
|
1256
|
+
# is: a ``raise_for_status`` on the next line would otherwise abandon
|
|
1257
|
+
# the connection holding one, and a manifest walks hundreds of these.
|
|
1258
|
+
with requests.get(
|
|
1259
|
+
url, timeout=STORAGE_TIMEOUT, stream=True
|
|
1260
|
+
) as response:
|
|
1261
|
+
response.raise_for_status()
|
|
1262
|
+
# Spooled rather than ``io.BytesIO(response.content)`` so the
|
|
1263
|
+
# compressed bytes and the decoded table are not both resident
|
|
1264
|
+
# at their full size: past the threshold the download goes to
|
|
1265
|
+
# disk and only the table is in memory. Streaming without this
|
|
1266
|
+
# buys nothing -- ``.content`` materialises the whole body
|
|
1267
|
+
# anyway, just later.
|
|
1268
|
+
with tempfile.SpooledTemporaryFile(
|
|
1269
|
+
max_size=STORAGE_SPOOL_BYTES
|
|
1270
|
+
) as spool:
|
|
1271
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
1272
|
+
spool.write(chunk)
|
|
1273
|
+
spool.seek(0)
|
|
1274
|
+
table = pq.read_table(
|
|
1275
|
+
spool, columns=list(columns) if columns else None
|
|
1276
|
+
)
|
|
1049
1277
|
return table.to_pandas()
|
|
1050
1278
|
|
|
1051
1279
|
|
|
@@ -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"})]
|