quantpad-data 0.5.0__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.
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/PKG-INFO +6 -1
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/README.md +5 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/pyproject.toml +1 -1
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/client.py +54 -1
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/memory.py +59 -3
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/v2.py +45 -9
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_client.py +50 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_v2.py +266 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/.gitignore +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/__init__.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/conftest.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_lean.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_packaging.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_rth.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.6.0}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.5.0 → 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.
|
|
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
|
|
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
|
|
|
84
84
|
"QuantPad support about /v2 access."
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
VERSION = "0.
|
|
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()
|
|
@@ -59,6 +59,34 @@ def _read_int(path: str) -> int | None:
|
|
|
59
59
|
return None
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _stat_map(path: str) -> dict[str, int]:
|
|
63
|
+
"""A cgroup ``memory.stat`` file as a mapping, empty if unreadable."""
|
|
64
|
+
out: dict[str, int] = {}
|
|
65
|
+
try:
|
|
66
|
+
with open(path) as handle:
|
|
67
|
+
for line in handle:
|
|
68
|
+
key, _, value = line.partition(" ")
|
|
69
|
+
try:
|
|
70
|
+
out[key] = int(value)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unreclaimable(stat: dict[str, int], keys: tuple[str, ...]) -> int | None:
|
|
79
|
+
"""What this cgroup holds that the kernel cannot take back.
|
|
80
|
+
|
|
81
|
+
Everything charged to a cgroup is *not* equally spent. Page cache is
|
|
82
|
+
charged, and is handed back the moment anything needs the memory, so
|
|
83
|
+
subtracting it from the limit describes a process that is nowhere near
|
|
84
|
+
its ceiling as though it were at it.
|
|
85
|
+
"""
|
|
86
|
+
present = [stat[key] for key in keys if key in stat]
|
|
87
|
+
return sum(present) if present else None
|
|
88
|
+
|
|
89
|
+
|
|
62
90
|
def _cgroup_free() -> int | None:
|
|
63
91
|
"""Headroom under this container's memory limit, if it has one.
|
|
64
92
|
|
|
@@ -66,12 +94,34 @@ def _cgroup_free() -> int | None:
|
|
|
66
94
|
reports the HOST's memory: in a sandbox on a large machine that is wrong
|
|
67
95
|
by an order of magnitude and wrong in the generous direction, which is
|
|
68
96
|
the direction that kills the process.
|
|
97
|
+
|
|
98
|
+
HEADROOM IS THE LIMIT LESS WHAT CANNOT BE RECLAIMED, NOT LESS WHAT IS
|
|
99
|
+
CHARGED, and the difference is the whole reason this function is not two
|
|
100
|
+
lines. ``memory.current`` includes page cache, so *writing a file* looks
|
|
101
|
+
identical to *allocating memory*: measured on a 4 GiB Daytona sandbox,
|
|
102
|
+
2 GiB of files on disk took this from 3.91 GiB to 1.93 GiB while the
|
|
103
|
+
process's own ``anon`` never left 0.09 GiB, and deleting the files put it
|
|
104
|
+
straight back. Under ``mode="auto"`` that halved the declared budget and
|
|
105
|
+
refused windows that fitted twice over, with a message naming memory the
|
|
106
|
+
caller had. The disk cache makes it worse by design: every partition it
|
|
107
|
+
writes, and every one it reads back, is more page cache.
|
|
108
|
+
|
|
109
|
+
Note this is also what makes the two sources answer the SAME question --
|
|
110
|
+
``MemAvailable`` already counts reclaimable cache as available, so the old
|
|
111
|
+
subtraction had them disagreeing and ``min()`` let the wrong one win.
|
|
69
112
|
"""
|
|
70
|
-
for limit_path, usage_path in (
|
|
71
|
-
(
|
|
113
|
+
for limit_path, usage_path, stat_path, keys in (
|
|
114
|
+
(
|
|
115
|
+
"/sys/fs/cgroup/memory.max",
|
|
116
|
+
"/sys/fs/cgroup/memory.current",
|
|
117
|
+
"/sys/fs/cgroup/memory.stat",
|
|
118
|
+
("anon", "unevictable", "slab_unreclaimable"),
|
|
119
|
+
),
|
|
72
120
|
(
|
|
73
121
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
74
122
|
"/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
|
123
|
+
"/sys/fs/cgroup/memory/memory.stat",
|
|
124
|
+
("total_rss", "total_unevictable"),
|
|
75
125
|
),
|
|
76
126
|
):
|
|
77
127
|
limit = _read_int(limit_path)
|
|
@@ -79,7 +129,13 @@ def _cgroup_free() -> int | None:
|
|
|
79
129
|
# None; v1 writes a sentinel. Both mean unlimited.
|
|
80
130
|
if limit is None or limit >= _NO_LIMIT:
|
|
81
131
|
continue
|
|
82
|
-
|
|
132
|
+
spent = _unreclaimable(_stat_map(stat_path), keys)
|
|
133
|
+
if spent is None:
|
|
134
|
+
# No usable stat file. Fall back to the charged figure, which is
|
|
135
|
+
# pessimistic rather than dangerous: it can refuse a request that
|
|
136
|
+
# would have fitted, and never admits one that will not.
|
|
137
|
+
spent = _read_int(usage_path) or 0
|
|
138
|
+
return max(limit - spent, 0)
|
|
83
139
|
return None
|
|
84
140
|
|
|
85
141
|
|
|
@@ -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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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,
|
|
@@ -1059,6 +1224,107 @@ class TestWhatThisProcessCanHold:
|
|
|
1059
1224
|
assert memory.available_bytes() is None
|
|
1060
1225
|
assert memory.declared_budget("auto") is None
|
|
1061
1226
|
|
|
1227
|
+
@staticmethod
|
|
1228
|
+
def _fake_cgroup(monkeypatch, *, v2=True, limit=4 * 2**30, stat=None,
|
|
1229
|
+
charged=None):
|
|
1230
|
+
"""Stand in for the cgroup files, since their paths are absolute."""
|
|
1231
|
+
limit_name = "memory.max" if v2 else "memory.limit_in_bytes"
|
|
1232
|
+
usage_name = "memory.current" if v2 else "memory.usage_in_bytes"
|
|
1233
|
+
|
|
1234
|
+
def read_int(path):
|
|
1235
|
+
if limit_name in path:
|
|
1236
|
+
return limit
|
|
1237
|
+
if usage_name in path:
|
|
1238
|
+
return charged
|
|
1239
|
+
return None
|
|
1240
|
+
|
|
1241
|
+
monkeypatch.setattr(memory, "_read_int", read_int)
|
|
1242
|
+
monkeypatch.setattr(memory, "_stat_map", lambda path: dict(stat or {}))
|
|
1243
|
+
|
|
1244
|
+
def test_page_cache_is_not_charged_to_the_caller(self, monkeypatch):
|
|
1245
|
+
"""The defect this arithmetic exists to stop, measured not imagined.
|
|
1246
|
+
|
|
1247
|
+
On a 4 GiB Daytona sandbox, writing 2 GiB of files took the detected
|
|
1248
|
+
figure from 3.91 GiB to 1.93 GiB while the process's own ``anon``
|
|
1249
|
+
stayed at 0.09 GiB, and deleting the files restored it exactly. Under
|
|
1250
|
+
``mode="auto"`` that halved the declared budget and refused windows
|
|
1251
|
+
that fitted twice over, blaming memory the caller had. The disk cache
|
|
1252
|
+
feeds it: every partition written and every one read back is page
|
|
1253
|
+
cache.
|
|
1254
|
+
"""
|
|
1255
|
+
limit = 4 * 2**30
|
|
1256
|
+
self._fake_cgroup(
|
|
1257
|
+
monkeypatch,
|
|
1258
|
+
limit=limit,
|
|
1259
|
+
# 2 GiB of page cache on top of a 100 MiB process.
|
|
1260
|
+
charged=100 * 2**20 + 2 * 2**30,
|
|
1261
|
+
stat={
|
|
1262
|
+
"anon": 100 * 2**20,
|
|
1263
|
+
"file": 2 * 2**30,
|
|
1264
|
+
"unevictable": 0,
|
|
1265
|
+
"slab_unreclaimable": 8 * 2**20,
|
|
1266
|
+
},
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
free = memory._cgroup_free()
|
|
1270
|
+
|
|
1271
|
+
assert free == limit - (100 * 2**20 + 8 * 2**20)
|
|
1272
|
+
# Emphatically not the charged figure, which is what it used to be.
|
|
1273
|
+
assert free > 3 * 2**30
|
|
1274
|
+
|
|
1275
|
+
def test_a_cgroup_v1_container_reads_its_resident_set(self, monkeypatch):
|
|
1276
|
+
"""v1 spells the same quantity ``total_rss`` rather than ``anon``."""
|
|
1277
|
+
limit = 2 * 2**30
|
|
1278
|
+
self._fake_cgroup(
|
|
1279
|
+
monkeypatch,
|
|
1280
|
+
v2=False,
|
|
1281
|
+
limit=limit,
|
|
1282
|
+
charged=limit, # wholly charged, almost all of it cache
|
|
1283
|
+
stat={
|
|
1284
|
+
"total_rss": 300 * 2**20,
|
|
1285
|
+
"total_cache": limit - 300 * 2**20,
|
|
1286
|
+
"total_unevictable": 0,
|
|
1287
|
+
},
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
assert memory._cgroup_free() == limit - 300 * 2**20
|
|
1291
|
+
|
|
1292
|
+
def test_a_process_that_really_is_near_its_limit_still_reads_as_full(
|
|
1293
|
+
self, monkeypatch
|
|
1294
|
+
):
|
|
1295
|
+
"""The guard must still guard.
|
|
1296
|
+
|
|
1297
|
+
Discounting page cache is only correct because it is reclaimable;
|
|
1298
|
+
anonymous memory is not, so a process that has genuinely filled its
|
|
1299
|
+
cgroup must still report almost nothing free.
|
|
1300
|
+
"""
|
|
1301
|
+
limit = 4 * 2**30
|
|
1302
|
+
self._fake_cgroup(
|
|
1303
|
+
monkeypatch,
|
|
1304
|
+
limit=limit,
|
|
1305
|
+
charged=limit,
|
|
1306
|
+
stat={"anon": limit - 64 * 2**20, "file": 32 * 2**20},
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
assert memory._cgroup_free() == 64 * 2**20
|
|
1310
|
+
assert memory.declared_budget("auto") < 16 * 2**20
|
|
1311
|
+
|
|
1312
|
+
def test_an_unreadable_stat_file_falls_back_to_the_charged_figure(
|
|
1313
|
+
self, monkeypatch
|
|
1314
|
+
):
|
|
1315
|
+
"""Pessimistic rather than dangerous.
|
|
1316
|
+
|
|
1317
|
+
Without ``memory.stat`` there is no way to separate cache from
|
|
1318
|
+
anonymous memory, and the old subtraction is the safe direction: it
|
|
1319
|
+
can refuse something that would have fitted, but never admits
|
|
1320
|
+
something that will not.
|
|
1321
|
+
"""
|
|
1322
|
+
limit = 4 * 2**30
|
|
1323
|
+
self._fake_cgroup(monkeypatch, limit=limit, stat={},
|
|
1324
|
+
charged=3 * 2**30)
|
|
1325
|
+
|
|
1326
|
+
assert memory._cgroup_free() == limit - 3 * 2**30
|
|
1327
|
+
|
|
1062
1328
|
def test_an_unlimited_cgroup_is_not_read_as_a_limit(self, monkeypatch):
|
|
1063
1329
|
"""v2 writes the literal "max" and v1 writes a huge sentinel.
|
|
1064
1330
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|