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.
Files changed (24) hide show
  1. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/PKG-INFO +1 -1
  2. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/pyproject.toml +1 -1
  3. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/client.py +1 -1
  4. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/memory.py +29 -3
  5. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/v2.py +82 -15
  6. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/conftest.py +11 -0
  7. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2.py +152 -5
  8. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/.gitignore +0 -0
  9. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/README.md +0 -0
  10. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/__init__.py +0 -0
  11. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/cache.py +0 -0
  12. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/errors.py +0 -0
  13. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/lean.py +0 -0
  14. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/py.typed +0 -0
  15. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/quantpad_data/rth.py +0 -0
  16. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_client.py +0 -0
  17. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_lean.py +0 -0
  18. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_message_encoding.py +0 -0
  19. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_packaging.py +0 -0
  20. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_refusal_hint.py +0 -0
  21. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_rth.py +0 -0
  22. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2_cache.py +0 -0
  23. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.8.3 → quantpad_data-0.8.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.8.3
3
+ Version: 0.8.4
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.8.3"
7
+ version = "0.8.4"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -87,7 +87,7 @@ V2_NOT_AVAILABLE = (
87
87
  "QuantPad support about /v2 access."
88
88
  )
89
89
 
90
- VERSION = "0.8.3"
90
+ VERSION = "0.8.4"
91
91
  USER_AGENT = f"quantpad-data-python/{VERSION}"
92
92
  TICK_SCHEMAS = (
93
93
  "trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
@@ -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.49x at the lowest), so a request priced above all available
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 / PEAK_PER_PRICED_BYTE)
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 = list(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
647
- for day in sorted(set(ready) | missing):
648
- while pending and pending[0][0] <= day:
649
- start, end = pending.pop(0)
650
- for payload in self._manifests(
651
- {**manifest_body, "start": start, "end": end}
652
- ):
653
- for item in payload["files"]:
654
- fetched.setdefault(item["day"], []).append((payload, item))
655
- yield from fresh(ready.get(day, ()))
656
- # A missing day that came back with nothing -- a holiday, or
657
- # a genuinely quiet session -- simply has no entry here, and
658
- # is skipped exactly as an ``empty`` day always was.
659
- yield from fresh(fetched.pop(day, ()))
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
- # One call so far: the cached day was served without the fetch
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.PEAK_PER_PRICED_BYTE
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