quantpad-data 0.8.1__tar.gz → 0.8.4__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.8.1 → quantpad_data-0.8.4}/PKG-INFO +1 -1
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/pyproject.toml +1 -1
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/__init__.py +2 -1
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/cache.py +21 -3
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/client.py +78 -6
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/errors.py +33 -1
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/memory.py +29 -3
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/v2.py +91 -17
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/conftest.py +11 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_client.py +122 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2.py +152 -5
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2_cache.py +77 -1
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/.gitignore +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/README.md +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_lean.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_packaging.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_refusal_hint.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_rth.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.8.1 → quantpad_data-0.8.4}/tests/test_version.py +0 -0
|
@@ -11,6 +11,7 @@ import pyarrow as pa
|
|
|
11
11
|
from .client import Client, ROLL_ADJUST_MODES, TICK_SCHEMAS, USER_AGENT, VERSION
|
|
12
12
|
from .errors import (
|
|
13
13
|
AuthenticationError,
|
|
14
|
+
IncompleteResponseError,
|
|
14
15
|
NotFoundError,
|
|
15
16
|
PermissionDeniedError,
|
|
16
17
|
QuantPadDataError,
|
|
@@ -213,7 +214,7 @@ __all__ = [
|
|
|
213
214
|
"Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
|
|
214
215
|
"PermissionDeniedError", "RateLimitError", "QuotaExceededError",
|
|
215
216
|
"NotFoundError", "V2NotAvailableError", "ValidationError", "UpstreamError",
|
|
216
|
-
"ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
|
|
217
|
+
"IncompleteResponseError", "ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
|
|
217
218
|
"ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
|
|
218
219
|
"get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
|
|
219
220
|
"get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
|
|
@@ -349,6 +349,12 @@ def _columns_tag(columns: Sequence[str] | None) -> str:
|
|
|
349
349
|
#: would serve the wrong rows.
|
|
350
350
|
_DELIVERY_ONLY = frozenset({"compression", "max_decoded_bytes"})
|
|
351
351
|
|
|
352
|
+
#: Changed whenever the service starts answering an unchanged body with
|
|
353
|
+
#: different rows, so every entry cached under the old answer misses once.
|
|
354
|
+
#: 2: until 2026-10, a window ending mid-day was answered without its last
|
|
355
|
+
#: day, and that short answer was cached here as the window's answer.
|
|
356
|
+
_RANGE_KEY_EPOCH = 2
|
|
357
|
+
|
|
352
358
|
|
|
353
359
|
def _request_key(body: dict) -> str:
|
|
354
360
|
"""A stable key for the answer a request body asks for.
|
|
@@ -357,7 +363,9 @@ def _request_key(body: dict) -> str:
|
|
|
357
363
|
which is what stops it drifting as ``get_range`` grows arguments.
|
|
358
364
|
"""
|
|
359
365
|
material = {k: v for k, v in sorted(body.items()) if k not in _DELIVERY_ONLY}
|
|
360
|
-
return _hash_key(
|
|
366
|
+
return _hash_key(
|
|
367
|
+
[str(_RANGE_KEY_EPOCH), json.dumps(material, sort_keys=True, default=str)]
|
|
368
|
+
)
|
|
361
369
|
|
|
362
370
|
|
|
363
371
|
def range_path(body: dict) -> Path:
|
|
@@ -403,13 +411,23 @@ def read_meta(path: Path) -> dict | None:
|
|
|
403
411
|
return _read_meta(path)
|
|
404
412
|
|
|
405
413
|
|
|
406
|
-
def get_frame(path: Path) -> pd.DataFrame | None:
|
|
414
|
+
def get_frame(path: Path, *, expect: dict | None = None) -> pd.DataFrame | None:
|
|
407
415
|
"""Read a cached frame, or None on miss.
|
|
408
416
|
|
|
409
417
|
A corrupt or half-written entry is dropped and reported as a miss, so a
|
|
410
418
|
damaged cache degrades to the network rather than failing the request.
|
|
419
|
+
|
|
420
|
+
``expect`` names sidecar fields the entry must carry exactly, and a
|
|
421
|
+
mismatch is a miss. This is how a partition the service has since
|
|
422
|
+
re-fetched stops being served from disk: the manifest's ``version``
|
|
423
|
+
moves when upstream corrects a day, and a settled day is otherwise
|
|
424
|
+
never asked about again. An entry written before versions existed
|
|
425
|
+
carries none, so it misses once against a server that sends one.
|
|
411
426
|
"""
|
|
412
|
-
|
|
427
|
+
meta = _read_meta(path) if enabled() else None
|
|
428
|
+
if meta is None:
|
|
429
|
+
return None
|
|
430
|
+
if any(meta.get(field) != value for field, value in (expect or {}).items()):
|
|
413
431
|
return None
|
|
414
432
|
try:
|
|
415
433
|
frame = pd.read_parquet(path)
|
|
@@ -10,11 +10,14 @@ from datetime import date
|
|
|
10
10
|
from typing import Iterator, Sequence
|
|
11
11
|
|
|
12
12
|
import pandas as pd
|
|
13
|
+
import pyarrow as pa
|
|
13
14
|
import pyarrow.ipc as ipc
|
|
14
15
|
import requests
|
|
16
|
+
import urllib3
|
|
15
17
|
|
|
16
18
|
from .errors import (
|
|
17
19
|
AuthenticationError,
|
|
20
|
+
IncompleteResponseError,
|
|
18
21
|
NotFoundError,
|
|
19
22
|
PermissionDeniedError,
|
|
20
23
|
QuantPadDataError,
|
|
@@ -84,7 +87,7 @@ V2_NOT_AVAILABLE = (
|
|
|
84
87
|
"QuantPad support about /v2 access."
|
|
85
88
|
)
|
|
86
89
|
|
|
87
|
-
VERSION = "0.8.
|
|
90
|
+
VERSION = "0.8.4"
|
|
88
91
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
89
92
|
TICK_SCHEMAS = (
|
|
90
93
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -142,6 +145,50 @@ BARS_TIMEOUT = (30, 1800)
|
|
|
142
145
|
#: stall detector and comes down to a couple of minutes.
|
|
143
146
|
V2_DATA_TIMEOUT = (10, 1800)
|
|
144
147
|
|
|
148
|
+
#: Backoff between retries: exponential, with jitter, so that the clients
|
|
149
|
+
#: of one outage do not come back in lockstep and knock it over again.
|
|
150
|
+
#:
|
|
151
|
+
#: Was 0.5s, 1s, 2s plus at most 0.25s, with the server's ``Retry-After``
|
|
152
|
+
#: ignored on every 5xx. During the 2026-10-01/02 API-key outage the
|
|
153
|
+
#: service answered every request "503, Retry-After: 5"; this client
|
|
154
|
+
#: retried each call three more times inside four seconds, and one key
|
|
155
|
+
#: kept that up for sixteen hours.
|
|
156
|
+
RETRY_BACKOFF_BASE = 1.0
|
|
157
|
+
RETRY_BACKOFF_MAX = 30.0
|
|
158
|
+
|
|
159
|
+
#: The longest ``Retry-After`` honoured, from any status. Past this the
|
|
160
|
+
#: client would rather fail and let its caller decide than sleep silently:
|
|
161
|
+
#: a daily-quota 429 can say "come back in twenty hours".
|
|
162
|
+
RETRY_AFTER_MAX = 60.0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
#: What reading a stream raises when the connection ends early: urllib3's
|
|
166
|
+
#: incomplete-chunk error from the socket, requests' wrapper of the same,
|
|
167
|
+
#: and what pyarrow raises when a message is cut short (ArrowInvalid) or
|
|
168
|
+
#: its read fails (OSError).
|
|
169
|
+
_BROKEN_STREAM = (
|
|
170
|
+
urllib3.exceptions.HTTPError,
|
|
171
|
+
requests.exceptions.RequestException,
|
|
172
|
+
pa.ArrowInvalid,
|
|
173
|
+
OSError,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _retry_delay(attempt: int, retry_after: float | None) -> float:
|
|
178
|
+
"""Seconds to wait before retry number ``attempt + 1``.
|
|
179
|
+
|
|
180
|
+
Exponential with equal jitter -- half the step fixed, half random --
|
|
181
|
+
and never sooner than the server asked, though no later than
|
|
182
|
+
``RETRY_AFTER_MAX``. The server's figure is jittered too, because
|
|
183
|
+
every client of an outage receives the same one.
|
|
184
|
+
"""
|
|
185
|
+
step = min(RETRY_BACKOFF_MAX, RETRY_BACKOFF_BASE * (2**attempt))
|
|
186
|
+
delay = step / 2 + random.uniform(0, step / 2)
|
|
187
|
+
if retry_after is not None and retry_after > 0:
|
|
188
|
+
asked = min(float(retry_after), RETRY_AFTER_MAX)
|
|
189
|
+
delay = max(delay, asked + random.uniform(0, min(1.0, asked / 4)))
|
|
190
|
+
return delay
|
|
191
|
+
|
|
145
192
|
|
|
146
193
|
def _prose_with_hint(error: dict) -> str:
|
|
147
194
|
"""The sentence to raise from a structured refusal.
|
|
@@ -269,7 +316,7 @@ class Client:
|
|
|
269
316
|
cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
|
|
270
317
|
raise cls(message, retry_after, status)
|
|
271
318
|
if status >= 500:
|
|
272
|
-
raise UpstreamError(message, status)
|
|
319
|
+
raise UpstreamError(message, status, retry_after=retry_after)
|
|
273
320
|
raise QuantPadDataError(message, status, detail)
|
|
274
321
|
|
|
275
322
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
@@ -322,9 +369,8 @@ class Client:
|
|
|
322
369
|
last_error = exc
|
|
323
370
|
if attempt >= self.max_retries:
|
|
324
371
|
raise
|
|
325
|
-
delay = None
|
|
326
|
-
|
|
327
|
-
time.sleep(max(0.0, base_delay) + random.uniform(0, 0.25))
|
|
372
|
+
delay = getattr(exc, "retry_after", None)
|
|
373
|
+
time.sleep(_retry_delay(attempt, delay))
|
|
328
374
|
raise QuantPadDataError(str(last_error or "Request failed"))
|
|
329
375
|
|
|
330
376
|
@staticmethod
|
|
@@ -417,8 +463,34 @@ class Client:
|
|
|
417
463
|
response.raw.decode_content = True
|
|
418
464
|
buffered: list[pd.DataFrame] = []
|
|
419
465
|
buffered_rows = 0
|
|
466
|
+
received = 0
|
|
467
|
+
|
|
468
|
+
def broken(exc: BaseException) -> IncompleteResponseError:
|
|
469
|
+
return IncompleteResponseError(
|
|
470
|
+
f"The /v1/ticks stream for {params['symbol']} {schema} ended "
|
|
471
|
+
f"before the service finished sending it ({received:,} rows "
|
|
472
|
+
"had arrived). Nothing from this call is complete; retry the "
|
|
473
|
+
f"request. ({type(exc).__name__}: {exc})",
|
|
474
|
+
rows_received=received,
|
|
475
|
+
)
|
|
476
|
+
|
|
420
477
|
try:
|
|
421
|
-
|
|
478
|
+
# Read errors are told apart from a clean end because the
|
|
479
|
+
# service now ends a failed stream by closing the connection
|
|
480
|
+
# early -- its 200 has already gone out -- and the raw urllib3
|
|
481
|
+
# ProtocolError that surfaces says nothing about what it means.
|
|
482
|
+
try:
|
|
483
|
+
batches = iter(ipc.open_stream(response.raw))
|
|
484
|
+
except _BROKEN_STREAM as exc:
|
|
485
|
+
raise broken(exc) from exc
|
|
486
|
+
while True:
|
|
487
|
+
try:
|
|
488
|
+
batch = next(batches)
|
|
489
|
+
except StopIteration:
|
|
490
|
+
break
|
|
491
|
+
except _BROKEN_STREAM as exc:
|
|
492
|
+
raise broken(exc) from exc
|
|
493
|
+
received += batch.num_rows
|
|
422
494
|
if not batch.num_rows:
|
|
423
495
|
continue
|
|
424
496
|
buffered.append(batch.to_pandas(split_blocks=True, self_destruct=True))
|
|
@@ -134,7 +134,39 @@ class ValidationError(ValueError, QuantPadDataError):
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
class UpstreamError(QuantPadDataError):
|
|
137
|
-
|
|
137
|
+
"""A 5xx from the service.
|
|
138
|
+
|
|
139
|
+
``retry_after`` is the service's ``Retry-After`` in seconds when it sent
|
|
140
|
+
one: a 503 that says when to come back is a 503 worth believing, and the
|
|
141
|
+
client waits at least that long before retrying it.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
message: str,
|
|
147
|
+
status_code: int | None = None,
|
|
148
|
+
detail: dict | None = None,
|
|
149
|
+
*,
|
|
150
|
+
retry_after: float | None = None,
|
|
151
|
+
):
|
|
152
|
+
super().__init__(message, status_code, detail)
|
|
153
|
+
self.retry_after = retry_after
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class IncompleteResponseError(UpstreamError):
|
|
157
|
+
"""A streamed response ended before the service finished sending it.
|
|
158
|
+
|
|
159
|
+
The service now ends a tick stream this way when it fails partway --
|
|
160
|
+
the status was already 200, so the connection is the only signal
|
|
161
|
+
left. Nothing this call returned can be taken as complete; retry the
|
|
162
|
+
request. A subclass of :class:`UpstreamError`, so existing ``except
|
|
163
|
+
UpstreamError`` handlers keep catching it. ``rows_received`` counts
|
|
164
|
+
the rows that had arrived when the stream broke.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, message: str, rows_received: int = 0):
|
|
168
|
+
super().__init__(message)
|
|
169
|
+
self.rows_received = rows_received
|
|
138
170
|
|
|
139
171
|
|
|
140
172
|
class QuantPadDataNotReady(QuantPadDataError):
|
|
@@ -42,6 +42,29 @@ import os
|
|
|
42
42
|
#: returns to killing the process with no message.
|
|
43
43
|
PEAK_PER_PRICED_BYTE = 6.0
|
|
44
44
|
|
|
45
|
+
#: Measured factors for the schemas where the 6x worst case refused what
|
|
46
|
+
#: fitted -- in 14 days of production sandboxes, 31 of 74 refusals were ~1 GB
|
|
47
|
+
#: or less, mostly single days of 80-byte quote records. Measured 2026-10-28
|
|
48
|
+
#: (peak RSS over priced bytes): mbp-10 2.37x and 2.57x; mbp-1 3.17x-3.19x on
|
|
49
|
+
#: full days near the budget and 4.32x on a 63 MB one; trades 4.73x, so trades
|
|
50
|
+
#: keeps the default. The other quote schemas share mbp-1's record shape.
|
|
51
|
+
#: Kept identical to the sandbox client's ``_PEAK_BY_SCHEMA``.
|
|
52
|
+
PEAK_BY_SCHEMA = {
|
|
53
|
+
"mbp-10": 3.0,
|
|
54
|
+
"mbp-1": 4.5,
|
|
55
|
+
"tbbo": 4.5,
|
|
56
|
+
"bbo-1s": 4.5,
|
|
57
|
+
"bbo-1m": 4.5,
|
|
58
|
+
"cmbp-1": 4.5,
|
|
59
|
+
"tcbbo": 4.5,
|
|
60
|
+
"cbbo-1s": 4.5,
|
|
61
|
+
"cbbo-1m": 4.5,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def peak_per_priced_byte(schema: str | None) -> float:
|
|
66
|
+
return PEAK_BY_SCHEMA.get(schema, PEAK_PER_PRICED_BYTE)
|
|
67
|
+
|
|
45
68
|
#: cgroup v1 spells "no limit" as a huge number rather than a word, and the
|
|
46
69
|
#: exact sentinel varies with page size, so anything absurd is read as absent.
|
|
47
70
|
_NO_LIMIT = 1 << 53
|
|
@@ -172,7 +195,7 @@ def available_bytes() -> int | None:
|
|
|
172
195
|
return min(candidates) if candidates else None
|
|
173
196
|
|
|
174
197
|
|
|
175
|
-
def declared_budget(mode: str) -> int | None:
|
|
198
|
+
def declared_budget(mode: str, schema: str | None = None) -> int | None:
|
|
176
199
|
"""The figure to send as ``max_decoded_bytes``, in the service's units.
|
|
177
200
|
|
|
178
201
|
TWO NUMBERS, BECAUSE THE COST OF BEING WRONG DIFFERS BY MODE.
|
|
@@ -186,9 +209,12 @@ def declared_budget(mode: str) -> int | None:
|
|
|
186
209
|
and there is nowhere to degrade to, so a refusal is a hard failure. It
|
|
187
210
|
therefore declares free memory UNDIVIDED, which refuses only what cannot
|
|
188
211
|
possibly fit: peak is larger than the priced figure in every case ever
|
|
189
|
-
measured (2.
|
|
212
|
+
measured (2.37x at the lowest), so a request priced above all available
|
|
190
213
|
memory is a certain MemoryError rather than a risk of one. Nothing that
|
|
191
214
|
works today starts failing.
|
|
215
|
+
|
|
216
|
+
Under ``"auto"`` the divisor is the schema's measured factor where one
|
|
217
|
+
exists (:data:`PEAK_BY_SCHEMA`), and the worst case otherwise.
|
|
192
218
|
"""
|
|
193
219
|
override = os.getenv(BUDGET_ENV, "").strip()
|
|
194
220
|
if override:
|
|
@@ -203,4 +229,4 @@ def declared_budget(mode: str) -> int | None:
|
|
|
203
229
|
return None
|
|
204
230
|
if mode == "interactive":
|
|
205
231
|
return free
|
|
206
|
-
return int(free /
|
|
232
|
+
return int(free / peak_per_priced_byte(schema))
|
|
@@ -23,10 +23,13 @@ request.
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
import collections
|
|
27
|
+
import concurrent.futures
|
|
26
28
|
import io
|
|
27
29
|
import json
|
|
28
30
|
import os
|
|
29
31
|
import tempfile
|
|
32
|
+
import threading
|
|
30
33
|
import warnings
|
|
31
34
|
from datetime import date, timedelta
|
|
32
35
|
from typing import Any, Iterator, Sequence
|
|
@@ -83,6 +86,39 @@ STORAGE_SPOOL_BYTES = 32 * 1024 * 1024
|
|
|
83
86
|
#: all -- it has no gaps, so it stays the single manifest it always was.
|
|
84
87
|
HYDRATE_CHUNK_DAYS = 5
|
|
85
88
|
|
|
89
|
+
#: How many of those runs hydrate at once. One at a time, each waited for
|
|
90
|
+
#: the one before it, so a cold window cost the SUM of its fetches: measured
|
|
91
|
+
#: 2026-10-28 on production, a cold 12-day GF.v.0 window took 99.0s one at a
|
|
92
|
+
#: time and 33.6s four at a time, with identical rows. Hydration returns a
|
|
93
|
+
#: manifest, not rows, so running ahead costs this process nothing; frames
|
|
94
|
+
#: are still read and yielded one at a time, in order. Kept identical to
|
|
95
|
+
#: the sandbox client's ``V2_HYDRATE_PARALLEL``.
|
|
96
|
+
HYDRATE_PARALLEL = 4
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _in_background(fn, *args) -> "concurrent.futures.Future":
|
|
100
|
+
"""Run ``fn(*args)`` on a DAEMON thread and return its future.
|
|
101
|
+
|
|
102
|
+
Not a ``ThreadPoolExecutor``: its workers are joined at interpreter
|
|
103
|
+
exit, and a hydration already in flight cannot be cancelled, so a
|
|
104
|
+
script that stopped iterating a cold window early would hang on exit
|
|
105
|
+
until those requests returned -- up to the data timeout. A daemon
|
|
106
|
+
thread is abandoned at exit instead. Concurrency stays bounded by the
|
|
107
|
+
caller, which keeps at most ``HYDRATE_PARALLEL`` unconsumed.
|
|
108
|
+
"""
|
|
109
|
+
future: concurrent.futures.Future = concurrent.futures.Future()
|
|
110
|
+
|
|
111
|
+
def run() -> None:
|
|
112
|
+
if not future.set_running_or_notify_cancel():
|
|
113
|
+
return
|
|
114
|
+
try:
|
|
115
|
+
future.set_result(fn(*args))
|
|
116
|
+
except BaseException as exc: # noqa: BLE001 - handed to the consumer
|
|
117
|
+
future.set_exception(exc)
|
|
118
|
+
|
|
119
|
+
threading.Thread(target=run, name="qpd-hydrate", daemon=True).start()
|
|
120
|
+
return future
|
|
121
|
+
|
|
86
122
|
|
|
87
123
|
def _symbols(value: str | Sequence[str]) -> list[str]:
|
|
88
124
|
return [value] if isinstance(value, str) else [str(v) for v in value]
|
|
@@ -341,7 +377,7 @@ class Timeseries(_Namespace):
|
|
|
341
377
|
# service too old to know the field, which ignores unknown keys.
|
|
342
378
|
budget = (
|
|
343
379
|
max_decoded_bytes if max_decoded_bytes is not None
|
|
344
|
-
else memory.declared_budget(mode)
|
|
380
|
+
else memory.declared_budget(mode, schema)
|
|
345
381
|
)
|
|
346
382
|
if budget is not None:
|
|
347
383
|
# A MEASURED ZERO is not the same as "could not measure", and
|
|
@@ -643,20 +679,43 @@ class Timeseries(_Namespace):
|
|
|
643
679
|
# One cursor over the union of both sets, so a fetched day lands
|
|
644
680
|
# between the cached days that surround it.
|
|
645
681
|
fetched: dict[str, list[tuple[dict, dict]]] = {}
|
|
646
|
-
pending =
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
682
|
+
pending = collections.deque(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
|
|
683
|
+
|
|
684
|
+
def hydrate(start: str, end: str) -> list[dict]:
|
|
685
|
+
return list(self._manifests({**manifest_body, "start": start, "end": end}))
|
|
686
|
+
|
|
687
|
+
# Runs hydrate up to HYDRATE_PARALLEL at a time, ahead of the
|
|
688
|
+
# cursor, and are CONSUMED in order: a run's files join ``fetched``
|
|
689
|
+
# only when the cursor reaches its first day, exactly as when they
|
|
690
|
+
# were fetched there, so the order frames come out in is unchanged.
|
|
691
|
+
inflight: collections.deque = collections.deque()
|
|
692
|
+
|
|
693
|
+
def top_up() -> None:
|
|
694
|
+
while pending and len(inflight) < HYDRATE_PARALLEL:
|
|
695
|
+
start, end = pending.popleft()
|
|
696
|
+
inflight.append((start, _in_background(hydrate, start, end)))
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
top_up()
|
|
700
|
+
for day in sorted(set(ready) | missing):
|
|
701
|
+
while inflight and inflight[0][0] <= day:
|
|
702
|
+
_, future = inflight.popleft()
|
|
703
|
+
# Re-raises the run's own error here, at the day it
|
|
704
|
+
# covers, which is where the sequential loop raised it.
|
|
705
|
+
for payload in future.result():
|
|
706
|
+
for item in payload["files"]:
|
|
707
|
+
fetched.setdefault(item["day"], []).append((payload, item))
|
|
708
|
+
top_up()
|
|
709
|
+
yield from fresh(ready.get(day, ()))
|
|
710
|
+
# A missing day that came back with nothing -- a holiday, or
|
|
711
|
+
# a genuinely quiet session -- simply has no entry here, and
|
|
712
|
+
# is skipped exactly as an ``empty`` day always was.
|
|
713
|
+
yield from fresh(fetched.pop(day, ()))
|
|
714
|
+
finally:
|
|
715
|
+
# A caller that stops iterating early: runs already started finish
|
|
716
|
+
# on their daemon threads and are dropped; none is started after.
|
|
717
|
+
for _, future in inflight:
|
|
718
|
+
future.cancel()
|
|
660
719
|
|
|
661
720
|
# Anything a fetch returned for a day the survey did not list.
|
|
662
721
|
# It should be empty, and it is drained rather than asserted
|
|
@@ -979,6 +1038,14 @@ class Options(_Namespace):
|
|
|
979
1038
|
instrument_class: str | None = None,
|
|
980
1039
|
limit: int | None = None,
|
|
981
1040
|
) -> pd.DataFrame:
|
|
1041
|
+
"""Outright calls and puts for ``underlier`` as of ``date``.
|
|
1042
|
+
|
|
1043
|
+
``underlier`` is the parent (``SPY.OPT``, ``NQ.OPT``); a futures
|
|
1044
|
+
option handle such as ``CL.FUT.OPT`` is accepted and sent as the
|
|
1045
|
+
parent upstream knows (``LO.OPT``). Spreads are left out unless
|
|
1046
|
+
their class is asked for (``instrument_class="T"``), and
|
|
1047
|
+
``expiration`` is a ``YYYY-MM-DD`` UTC date.
|
|
1048
|
+
"""
|
|
982
1049
|
params = {
|
|
983
1050
|
"dataset": dataset,
|
|
984
1051
|
"underlier": underlier,
|
|
@@ -1330,7 +1397,13 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
|
|
|
1330
1397
|
hostile ``QPD_CACHE_DIR`` can contain, and the only other way it could
|
|
1331
1398
|
fail is a manifest item missing ``day`` or ``instrument_id``, which the
|
|
1332
1399
|
caller subscripts for its own labels two lines later regardless.
|
|
1400
|
+
|
|
1401
|
+
A settled day is not immutable: the service re-fetches a day its
|
|
1402
|
+
vendor regenerates, and says so by changing the item's ``version``. A
|
|
1403
|
+
hit therefore has to be the version the manifest describes, or it is
|
|
1404
|
+
the uncorrected copy served from disk for as long as the disk keeps it.
|
|
1333
1405
|
"""
|
|
1406
|
+
expect = {"version": item.get("version")}
|
|
1334
1407
|
path = None
|
|
1335
1408
|
if cache.enabled() and cache.day_settled(item.get("day")):
|
|
1336
1409
|
path = cache.day_path(
|
|
@@ -1343,14 +1416,15 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
|
|
|
1343
1416
|
)
|
|
1344
1417
|
|
|
1345
1418
|
if path is not None:
|
|
1346
|
-
hit = cache.get_frame(path)
|
|
1419
|
+
hit = cache.get_frame(path, expect=expect)
|
|
1347
1420
|
if hit is not None:
|
|
1348
1421
|
return hit
|
|
1349
1422
|
|
|
1350
1423
|
frame = _read_parquet_url(item["url"], columns)
|
|
1351
1424
|
if path is not None:
|
|
1352
1425
|
cache.put_frame(
|
|
1353
|
-
path, frame,
|
|
1426
|
+
path, frame,
|
|
1427
|
+
{"day": item["day"], "dataset": body["dataset"], **expect},
|
|
1354
1428
|
)
|
|
1355
1429
|
return frame
|
|
1356
1430
|
|
|
@@ -24,3 +24,14 @@ def isolated_cache(tmp_path, monkeypatch):
|
|
|
24
24
|
monkeypatch.delenv("QPD_CACHE", raising=False)
|
|
25
25
|
monkeypatch.delenv("QPD_CACHE_MAX_BYTES", raising=False)
|
|
26
26
|
return tmp_path / "qpd-cache"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def one_hydration_at_a_time(monkeypatch):
|
|
31
|
+
"""The fake sessions replay responses in CALL order, and parallel
|
|
32
|
+
hydrations would race for them, so tests hydrate one run at a time.
|
|
33
|
+
The parallel path has its own tests, with a session keyed on the window
|
|
34
|
+
each request asks for (``test_v2.WindowSession``)."""
|
|
35
|
+
from quantpad_data import v2
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 1)
|
|
@@ -164,6 +164,128 @@ def test_rate_limit_exposes_retry_after():
|
|
|
164
164
|
assert caught.value.retry_after == 7
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
def _record_sleeps(monkeypatch) -> list[float]:
|
|
168
|
+
import quantpad_data.client as client_module
|
|
169
|
+
|
|
170
|
+
slept: list[float] = []
|
|
171
|
+
monkeypatch.setattr(client_module.time, "sleep", slept.append)
|
|
172
|
+
return slept
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_a_503_that_says_when_to_come_back_is_believed(monkeypatch):
|
|
176
|
+
"""During the 2026-10-01/02 API-key outage every answer was "503,
|
|
177
|
+
Retry-After: 5", and this client retried each call three more times
|
|
178
|
+
inside four seconds -- one key kept that up for sixteen hours."""
|
|
179
|
+
from quantpad_data.errors import UpstreamError
|
|
180
|
+
|
|
181
|
+
slept = _record_sleeps(monkeypatch)
|
|
182
|
+
unavailable = b'{"error":"API-key validation is temporarily unavailable"}'
|
|
183
|
+
session = FakeSession(
|
|
184
|
+
[response(503, unavailable, {"Retry-After": "5"}) for _ in range(4)]
|
|
185
|
+
)
|
|
186
|
+
client = Client(api_key="secret", session=session, max_retries=3)
|
|
187
|
+
|
|
188
|
+
with pytest.raises(UpstreamError) as caught:
|
|
189
|
+
client.get_coverage("AAPL")
|
|
190
|
+
|
|
191
|
+
assert caught.value.retry_after == 5
|
|
192
|
+
assert len(session.calls) == 4, "retries are capped at max_retries"
|
|
193
|
+
assert len(slept) == 3
|
|
194
|
+
assert all(delay >= 5 for delay in slept), slept
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_backoff_doubles_with_jitter_when_the_server_names_no_time(monkeypatch):
|
|
198
|
+
from quantpad_data.client import RETRY_BACKOFF_BASE
|
|
199
|
+
from quantpad_data.errors import UpstreamError
|
|
200
|
+
|
|
201
|
+
slept = _record_sleeps(monkeypatch)
|
|
202
|
+
session = FakeSession([response(502, b"bad gateway") for _ in range(4)])
|
|
203
|
+
client = Client(api_key="secret", session=session, max_retries=3)
|
|
204
|
+
|
|
205
|
+
with pytest.raises(UpstreamError):
|
|
206
|
+
client.get_coverage("AAPL")
|
|
207
|
+
|
|
208
|
+
for attempt, delay in enumerate(slept):
|
|
209
|
+
step = RETRY_BACKOFF_BASE * 2**attempt
|
|
210
|
+
assert step / 2 <= delay <= step, (attempt, delay)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_jitter_actually_spreads_clients_out():
|
|
214
|
+
from quantpad_data.client import _retry_delay
|
|
215
|
+
|
|
216
|
+
assert len({round(_retry_delay(2, 5), 6) for _ in range(50)}) > 1
|
|
217
|
+
assert len({round(_retry_delay(2, None), 6) for _ in range(50)}) > 1
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_an_absurd_retry_after_is_capped_rather_than_slept(monkeypatch):
|
|
221
|
+
"""A daily-quota 429 can say "come back in twenty hours"; sleeping
|
|
222
|
+
that long without a word is worse than failing."""
|
|
223
|
+
from quantpad_data.client import RETRY_AFTER_MAX, _retry_delay
|
|
224
|
+
|
|
225
|
+
assert _retry_delay(0, 72_000) <= RETRY_AFTER_MAX + 1.0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _trades_stream(*batch_rows: int, complete: bool) -> bytes:
|
|
229
|
+
"""An Arrow IPC tick stream; ``complete=False`` stops before the
|
|
230
|
+
end-of-stream marker, as a body cut off by the connection would."""
|
|
231
|
+
schema = pa.schema([("t", pa.int64()), ("price", pa.float64())])
|
|
232
|
+
sink = io.BytesIO()
|
|
233
|
+
writer = ipc.new_stream(sink, schema)
|
|
234
|
+
for rows in batch_rows:
|
|
235
|
+
writer.write_batch(pa.record_batch(
|
|
236
|
+
[pa.array(range(rows), pa.int64()), pa.array([1.0] * rows)],
|
|
237
|
+
schema=schema,
|
|
238
|
+
))
|
|
239
|
+
if complete:
|
|
240
|
+
writer.close()
|
|
241
|
+
return sink.getvalue()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _CutConnection(io.BytesIO):
|
|
245
|
+
"""A raw body that ends the way an aborted chunked response does:
|
|
246
|
+
after the bytes that arrived, the next read fails rather than
|
|
247
|
+
returning empty."""
|
|
248
|
+
|
|
249
|
+
def read(self, size=-1):
|
|
250
|
+
data = super().read(size)
|
|
251
|
+
if not data:
|
|
252
|
+
import urllib3
|
|
253
|
+
|
|
254
|
+
raise urllib3.exceptions.ProtocolError(
|
|
255
|
+
"Connection broken: IncompleteRead(0 bytes read)"
|
|
256
|
+
)
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_a_tick_stream_cut_short_raises_an_error_that_says_so():
|
|
261
|
+
"""The service ends a tick stream that fails partway by closing the
|
|
262
|
+
connection -- its 200 has already gone out. What surfaced before was
|
|
263
|
+
urllib3's bare ProtocolError; a caller has to be told that what they
|
|
264
|
+
received is incomplete and that retrying is the answer."""
|
|
265
|
+
from quantpad_data.errors import IncompleteResponseError, UpstreamError
|
|
266
|
+
|
|
267
|
+
cut = response(200, b"")
|
|
268
|
+
cut.raw = _CutConnection(_trades_stream(4, 6, complete=False))
|
|
269
|
+
client = Client(api_key="secret", session=FakeSession([cut]), max_retries=0)
|
|
270
|
+
|
|
271
|
+
with pytest.raises(IncompleteResponseError) as caught:
|
|
272
|
+
list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
|
|
273
|
+
|
|
274
|
+
assert isinstance(caught.value, UpstreamError), "existing handlers must still catch it"
|
|
275
|
+
assert caught.value.rows_received == 10
|
|
276
|
+
assert "retry" in str(caught.value).lower()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_a_complete_tick_stream_is_unaffected():
|
|
280
|
+
whole = response(200, b"")
|
|
281
|
+
whole.raw = io.BytesIO(_trades_stream(4, 6, complete=True))
|
|
282
|
+
client = Client(api_key="secret", session=FakeSession([whole]), max_retries=0)
|
|
283
|
+
|
|
284
|
+
frames = list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
|
|
285
|
+
|
|
286
|
+
assert sum(len(frame) for frame in frames) == 10
|
|
287
|
+
|
|
288
|
+
|
|
167
289
|
def test_universe_and_coverage_paths():
|
|
168
290
|
session = FakeSession(
|
|
169
291
|
[response(body=b'{"results":[]}'), response(body=b'{"symbol":"AAPL"}')]
|
|
@@ -10,6 +10,8 @@ month of depth data is a normal thing to do.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import threading
|
|
14
|
+
|
|
13
15
|
import io
|
|
14
16
|
import json
|
|
15
17
|
from datetime import date, timedelta
|
|
@@ -473,11 +475,20 @@ class TestBulkDelivery:
|
|
|
473
475
|
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
|
|
474
476
|
start="2026-01-05", end="2026-01-07",
|
|
475
477
|
)
|
|
478
|
+
# The fetch starts in the background straight away now, so it is
|
|
479
|
+
# held open: the cached frame must arrive without waiting for it.
|
|
480
|
+
gate = threading.Event()
|
|
481
|
+
replay = session.request
|
|
482
|
+
|
|
483
|
+
def held(method, url, **kwargs):
|
|
484
|
+
if session.calls: # every call after the survey is the fetch
|
|
485
|
+
assert gate.wait(5), "the cached frame never arrived"
|
|
486
|
+
return replay(method, url, **kwargs)
|
|
487
|
+
|
|
488
|
+
session.request = held
|
|
476
489
|
first = next(frames)
|
|
477
490
|
assert first.attrs["quantpad"]["day"] == "2026-01-05"
|
|
478
|
-
|
|
479
|
-
# for the missing one having been made.
|
|
480
|
-
assert len(session.calls) == 1
|
|
491
|
+
gate.set()
|
|
481
492
|
rest = list(frames)
|
|
482
493
|
assert [f.attrs["quantpad"]["day"] for f in rest] == ["2026-01-06"]
|
|
483
494
|
assert len(session.calls) == 2
|
|
@@ -1471,7 +1482,21 @@ class TestTheCallerDeclaresWhatItCanHold:
|
|
|
1471
1482
|
)
|
|
1472
1483
|
|
|
1473
1484
|
assert self._sent(session)["max_decoded_bytes"] == int(
|
|
1474
|
-
self.FREE / memory.
|
|
1485
|
+
self.FREE / memory.peak_per_priced_byte("mbp-10")
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
def test_the_divisor_is_the_schemas_measured_factor(self):
|
|
1489
|
+
"""6x refused single quote-record days that peak at ~3.2x."""
|
|
1490
|
+
for schema, factor in [("mbp-10", 3.0), ("mbp-1", 4.5), ("trades", 6.0),
|
|
1491
|
+
("definition", 6.0), (None, 6.0)]:
|
|
1492
|
+
assert memory.declared_budget("auto", schema) == int(self.FREE / factor)
|
|
1493
|
+
assert memory.declared_budget("interactive", "mbp-1") == self.FREE
|
|
1494
|
+
|
|
1495
|
+
def test_every_measured_factor_stays_above_its_reading(self):
|
|
1496
|
+
for schema, reading in {"mbp-10": 2.57, "mbp-1": 4.32}.items():
|
|
1497
|
+
assert memory.peak_per_priced_byte(schema) > reading
|
|
1498
|
+
assert all(
|
|
1499
|
+
f <= memory.PEAK_PER_PRICED_BYTE for f in memory.PEAK_BY_SCHEMA.values()
|
|
1475
1500
|
)
|
|
1476
1501
|
|
|
1477
1502
|
def test_interactive_declares_the_undivided_figure(self):
|
|
@@ -2297,4 +2322,126 @@ class TestDerivations:
|
|
|
2297
2322
|
stype_in="continuous", start="2026-01-05", end="2026-02-05",
|
|
2298
2323
|
resample="4h",
|
|
2299
2324
|
))
|
|
2300
|
-
assert session.calls == []
|
|
2325
|
+
assert session.calls == []
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
class WindowSession(FakeSession):
|
|
2329
|
+
"""Answers each manifest by the window it asks for, not by call order,
|
|
2330
|
+
because parallel hydrations reach it in whatever order their threads
|
|
2331
|
+
run. The first ``parties`` fetches wait at a barrier until all of them
|
|
2332
|
+
are in flight -- the proof that they overlap."""
|
|
2333
|
+
|
|
2334
|
+
def __init__(self, survey, parties=None):
|
|
2335
|
+
super().__init__([])
|
|
2336
|
+
self.survey = survey
|
|
2337
|
+
self.lock = threading.Lock()
|
|
2338
|
+
self.barrier = threading.Barrier(parties) if parties else None
|
|
2339
|
+
self.fetches = []
|
|
2340
|
+
|
|
2341
|
+
def request(self, method, url, **kwargs):
|
|
2342
|
+
body = kwargs.get("json") or {}
|
|
2343
|
+
with self.lock:
|
|
2344
|
+
self.calls.append((method, url, kwargs))
|
|
2345
|
+
if body.get("hydrate") is False:
|
|
2346
|
+
return self.survey
|
|
2347
|
+
self.fetches.append((body["start"], body["end"]))
|
|
2348
|
+
first_wave = len(self.fetches) <= (self.barrier.parties if self.barrier else 0)
|
|
2349
|
+
if first_wave:
|
|
2350
|
+
self.barrier.wait(5)
|
|
2351
|
+
start, end = date.fromisoformat(body["start"]), date.fromisoformat(body["end"])
|
|
2352
|
+
days = [(start + timedelta(days=i)).isoformat() for i in range((end - start).days)]
|
|
2353
|
+
return json_response({
|
|
2354
|
+
"format": "parquet",
|
|
2355
|
+
"files": [{"day": d, "instrument_id": 1, "key": d, "size_bytes": 1,
|
|
2356
|
+
"url": f"https://r2.test/{d}"} for d in days],
|
|
2357
|
+
"gaps": [],
|
|
2358
|
+
"metadata": {},
|
|
2359
|
+
})
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
class TestParallelHydration:
|
|
2363
|
+
"""Sequential hydration made a cold window cost the SUM of its fetches
|
|
2364
|
+
(99.0s against 33.6s four at a time, measured on production)."""
|
|
2365
|
+
|
|
2366
|
+
GAPS = [f"2026-01-{day:02d}" for day in range(5, 13)] # runs of 1, 2, 4, 1
|
|
2367
|
+
|
|
2368
|
+
def _iter(self, monkeypatch, session, end="2026-01-13"):
|
|
2369
|
+
from quantpad_data import v2
|
|
2370
|
+
|
|
2371
|
+
client = Client(api_key="k", base_url="https://api.test", session=session)
|
|
2372
|
+
monkeypatch.setattr(
|
|
2373
|
+
v2, "_read_parquet_url", lambda url, columns: pd.DataFrame({"url": [url]})
|
|
2374
|
+
)
|
|
2375
|
+
return Historical(client).timeseries.iter_range(
|
|
2376
|
+
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
|
|
2377
|
+
start="2026-01-05", end=end,
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
def _survey(self, gaps, cached=()):
|
|
2381
|
+
return json_response({
|
|
2382
|
+
"format": "parquet",
|
|
2383
|
+
"files": [{"day": d, "instrument_id": 1, "key": d, "size_bytes": 1,
|
|
2384
|
+
"url": f"https://r2.test/{d}"} for d in cached],
|
|
2385
|
+
"gaps": [{"day": d, "instrument_id": 1} for d in gaps],
|
|
2386
|
+
"metadata": {},
|
|
2387
|
+
})
|
|
2388
|
+
|
|
2389
|
+
def test_cold_runs_hydrate_at_the_same_time(self, monkeypatch):
|
|
2390
|
+
from quantpad_data import v2
|
|
2391
|
+
|
|
2392
|
+
monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 3)
|
|
2393
|
+
session = WindowSession(self._survey(self.GAPS), parties=3)
|
|
2394
|
+
|
|
2395
|
+
days = [f.attrs["quantpad"]["day"] for f in self._iter(monkeypatch, session)]
|
|
2396
|
+
|
|
2397
|
+
assert days == self.GAPS
|
|
2398
|
+
assert len(session.fetches) == 4
|
|
2399
|
+
|
|
2400
|
+
def test_the_window_stays_in_order_with_cached_days_in_place(self, monkeypatch):
|
|
2401
|
+
from quantpad_data import v2
|
|
2402
|
+
|
|
2403
|
+
monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 4)
|
|
2404
|
+
gaps = ["2026-01-05", "2026-01-07", "2026-01-08", "2026-01-10"]
|
|
2405
|
+
cached = ["2026-01-06", "2026-01-09"]
|
|
2406
|
+
session = WindowSession(self._survey(gaps, cached))
|
|
2407
|
+
|
|
2408
|
+
days = [f.attrs["quantpad"]["day"]
|
|
2409
|
+
for f in self._iter(monkeypatch, session, end="2026-01-11")]
|
|
2410
|
+
|
|
2411
|
+
assert days == sorted(gaps + cached)
|
|
2412
|
+
|
|
2413
|
+
def test_an_early_close_leaves_nothing_that_blocks_exit(self, monkeypatch):
|
|
2414
|
+
"""A hydration in flight cannot be cancelled, and pool workers are
|
|
2415
|
+
joined at interpreter exit -- so hydrations run on daemon threads,
|
|
2416
|
+
which a script that stops iterating early can exit past."""
|
|
2417
|
+
from quantpad_data import v2
|
|
2418
|
+
|
|
2419
|
+
monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 2)
|
|
2420
|
+
gate, started = threading.Event(), threading.Event()
|
|
2421
|
+
session = WindowSession(self._survey(self.GAPS))
|
|
2422
|
+
replay = session.request
|
|
2423
|
+
|
|
2424
|
+
def hanging(method, url, **kwargs):
|
|
2425
|
+
body = kwargs.get("json") or {}
|
|
2426
|
+
if body.get("hydrate") is not False and body["start"] != "2026-01-05":
|
|
2427
|
+
started.set()
|
|
2428
|
+
gate.wait(10)
|
|
2429
|
+
return replay(method, url, **kwargs)
|
|
2430
|
+
|
|
2431
|
+
session.request = hanging
|
|
2432
|
+
frames = self._iter(monkeypatch, session)
|
|
2433
|
+
assert next(frames).attrs["quantpad"]["day"] == "2026-01-05"
|
|
2434
|
+
assert started.wait(5)
|
|
2435
|
+
frames.close()
|
|
2436
|
+
hydrating = [t for t in threading.enumerate() if t.name == "qpd-hydrate"]
|
|
2437
|
+
assert hydrating and all(t.daemon for t in hydrating)
|
|
2438
|
+
gate.set()
|
|
2439
|
+
|
|
2440
|
+
def test_stopping_early_does_not_wait_on_queued_hydrations(self, monkeypatch):
|
|
2441
|
+
from quantpad_data import v2
|
|
2442
|
+
|
|
2443
|
+
monkeypatch.setattr(v2, "HYDRATE_PARALLEL", 2)
|
|
2444
|
+
frames = self._iter(monkeypatch, WindowSession(self._survey(self.GAPS)))
|
|
2445
|
+
|
|
2446
|
+
assert next(frames).attrs["quantpad"]["day"] == "2026-01-05"
|
|
2447
|
+
frames.close()
|
|
@@ -124,12 +124,13 @@ def api(*responses):
|
|
|
124
124
|
return Historical(client).timeseries, session
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def manifest_for(days, instrument_id=1551):
|
|
127
|
+
def manifest_for(days, instrument_id=1551, version=None):
|
|
128
128
|
return response(200, json.dumps({
|
|
129
129
|
"format": "parquet",
|
|
130
130
|
"files": [
|
|
131
131
|
{"day": day, "instrument_id": instrument_id, "symbol": "BA",
|
|
132
132
|
"key": f"k/{day}", "size_bytes": 10,
|
|
133
|
+
**({"version": version} if version is not None else {}),
|
|
133
134
|
"url": f"http://r2.test/{day}.parquet"}
|
|
134
135
|
for day in days
|
|
135
136
|
],
|
|
@@ -276,6 +277,17 @@ class TestWhatTheKeyMustSeparate:
|
|
|
276
277
|
base = _body(**ASK)
|
|
277
278
|
assert cache.range_path(base) == cache.range_path({**base, field: value})
|
|
278
279
|
|
|
280
|
+
def test_an_answer_cached_before_the_service_changed_it_is_not_served(
|
|
281
|
+
self, monkeypatch
|
|
282
|
+
):
|
|
283
|
+
# Until 2026-10 a window ending mid-day came back without its last
|
|
284
|
+
# day, and was cached that way. The body has not changed, so only the
|
|
285
|
+
# epoch can tell the old short answer from the new one.
|
|
286
|
+
body = _body(**{**ASK, "end": "2026-09-16T13:58:00Z"})
|
|
287
|
+
current = cache.range_path(body)
|
|
288
|
+
monkeypatch.setattr(cache, "_RANGE_KEY_EPOCH", cache._RANGE_KEY_EPOCH - 1)
|
|
289
|
+
assert cache.range_path(body) != current
|
|
290
|
+
|
|
279
291
|
|
|
280
292
|
class TestWhatMustNotBeCached:
|
|
281
293
|
def test_a_window_reaching_into_the_current_day_is_not_cached(self):
|
|
@@ -546,6 +558,70 @@ class TestPartitionsAreCachedIndividually:
|
|
|
546
558
|
)
|
|
547
559
|
|
|
548
560
|
|
|
561
|
+
class TestACorrectedDayIsNotServedFromDisk:
|
|
562
|
+
"""A settled day is final for the calendar, not for the vendor.
|
|
563
|
+
|
|
564
|
+
The service re-fetches a day its vendor regenerates and changes the
|
|
565
|
+
manifest item's ``version`` when it does. Before this, a day already on
|
|
566
|
+
disk was never asked about again, so the correction reached nobody who
|
|
567
|
+
had read the day once -- and nothing anywhere said the copy was old.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
CORRECTED = ROWS.assign(price=[100.5, 101.5])
|
|
571
|
+
|
|
572
|
+
def test_the_same_version_is_still_a_hit(self, monkeypatch):
|
|
573
|
+
urls = UrlRecorder()
|
|
574
|
+
monkeypatch.setattr(requests, "get", urls)
|
|
575
|
+
ts, _ = api(
|
|
576
|
+
manifest_for(["2026-09-16"], version="v1"),
|
|
577
|
+
manifest_for(["2026-09-16"], version="v1"),
|
|
578
|
+
)
|
|
579
|
+
list(ts.iter_range(**_manifest_ask()))
|
|
580
|
+
list(ts.iter_range(**_manifest_ask()))
|
|
581
|
+
assert len(urls.urls) == 1
|
|
582
|
+
|
|
583
|
+
def test_a_new_version_is_fetched_and_replaces_the_copy(self, monkeypatch):
|
|
584
|
+
urls = UrlRecorder()
|
|
585
|
+
monkeypatch.setattr(requests, "get", urls)
|
|
586
|
+
ts, _ = api(
|
|
587
|
+
manifest_for(["2026-09-16"], version="v1"),
|
|
588
|
+
manifest_for(["2026-09-16"], version="v2"),
|
|
589
|
+
manifest_for(["2026-09-16"], version="v2"),
|
|
590
|
+
)
|
|
591
|
+
list(ts.iter_range(**_manifest_ask()))
|
|
592
|
+
urls.frame = self.CORRECTED
|
|
593
|
+
[corrected] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
|
|
594
|
+
[again] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
|
|
595
|
+
|
|
596
|
+
assert len(urls.urls) == 2, "fetched once per version, not per read"
|
|
597
|
+
assert list(corrected["price"]) == [100.5, 101.5]
|
|
598
|
+
pd.testing.assert_frame_equal(corrected, again)
|
|
599
|
+
|
|
600
|
+
def test_a_copy_written_before_versions_existed_is_refetched_once(
|
|
601
|
+
self, monkeypatch
|
|
602
|
+
):
|
|
603
|
+
"""Every entry on disk today carries no version, and some of them
|
|
604
|
+
are the uncorrected days this exists for."""
|
|
605
|
+
urls = UrlRecorder()
|
|
606
|
+
monkeypatch.setattr(requests, "get", urls)
|
|
607
|
+
ts, _ = api(
|
|
608
|
+
manifest_for(["2026-09-16"]),
|
|
609
|
+
manifest_for(["2026-09-16"], version="v1"),
|
|
610
|
+
manifest_for(["2026-09-16"], version="v1"),
|
|
611
|
+
)
|
|
612
|
+
for _ in range(3):
|
|
613
|
+
list(ts.iter_range(**_manifest_ask()))
|
|
614
|
+
assert len(urls.urls) == 2
|
|
615
|
+
|
|
616
|
+
def test_a_range_entry_is_not_asked_for_a_version_it_never_had(self):
|
|
617
|
+
"""``expect`` is opt-in: the whole-response cache has no per-file
|
|
618
|
+
version to compare, and must keep its hits."""
|
|
619
|
+
path = cache.range_path(_body(**ASK))
|
|
620
|
+
assert cache.put_frame(path, ROWS, {"echo": {}})
|
|
621
|
+
assert cache.get_frame(path) is not None
|
|
622
|
+
assert cache.get_frame(path, expect={"version": "v1"}) is None
|
|
623
|
+
|
|
624
|
+
|
|
549
625
|
class TestTheSettlementRuleItself:
|
|
550
626
|
def test_a_past_day_is_settled(self):
|
|
551
627
|
assert cache.day_settled("2026-09-16") is True
|
|
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
|