quantpad-data 0.8.3__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.3 → quantpad_data-0.8.4}/PKG-INFO +1 -1
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/pyproject.toml +1 -1
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/client.py +1 -1
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/memory.py +29 -3
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/v2.py +82 -15
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/conftest.py +11 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2.py +152 -5
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/.gitignore +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/README.md +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/__init__.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_client.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_lean.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_packaging.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_refusal_hint.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_rth.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_version.py +0 -0
|
@@ -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,
|
|
@@ -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)
|
|
@@ -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()
|
|
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
|
|
File without changes
|
|
File without changes
|