quantpad-data 0.6.0__tar.gz → 0.7.1__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.6.0 → quantpad_data-0.7.1}/PKG-INFO +1 -1
  2. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/pyproject.toml +1 -1
  3. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/client.py +29 -2
  4. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/v2.py +208 -16
  5. quantpad_data-0.7.1/tests/test_refusal_hint.py +71 -0
  6. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2.py +526 -60
  7. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/.gitignore +0 -0
  8. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/README.md +0 -0
  9. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/__init__.py +0 -0
  10. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/cache.py +0 -0
  11. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/errors.py +0 -0
  12. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/lean.py +0 -0
  13. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/memory.py +0 -0
  14. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/py.typed +0 -0
  15. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/rth.py +0 -0
  16. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/conftest.py +0 -0
  17. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_client.py +0 -0
  18. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_lean.py +0 -0
  19. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_message_encoding.py +0 -0
  20. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_packaging.py +0 -0
  21. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_rth.py +0 -0
  22. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2_cache.py +0 -0
  23. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.6.0
3
+ Version: 0.7.1
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.6.0"
7
+ version = "0.7.1"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
84
84
  "QuantPad support about /v2 access."
85
85
  )
86
86
 
87
- VERSION = "0.6.0"
87
+ VERSION = "0.7.1"
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",
@@ -143,6 +143,25 @@ BARS_TIMEOUT = (30, 1800)
143
143
  V2_DATA_TIMEOUT = (10, 1800)
144
144
 
145
145
 
146
+ def _prose_with_hint(error: dict) -> str:
147
+ """The sentence to raise from a structured refusal.
148
+
149
+ ``message`` first; a ``hint`` follows it as a second sentence when the
150
+ service sent one. Kept out of the message on the wire so a caller
151
+ reading the body can still tell the refusal from the advice, and put
152
+ back here because the exception text is the only thing most callers
153
+ ever see. A body with a hint and no message is stringified whole, as
154
+ it always was.
155
+ """
156
+ message = error.get("message")
157
+ if not message:
158
+ return str(error)
159
+ hint = error.get("hint")
160
+ if isinstance(hint, str) and hint.strip():
161
+ return f"{message} {hint.strip()}"
162
+ return str(message)
163
+
164
+
146
165
  class Client:
147
166
  def __init__(
148
167
  self,
@@ -189,6 +208,14 @@ class Client:
189
208
  user where the service had written a sentence. So the prose is
190
209
  unwrapped from ``message`` when there is one, and the object is
191
210
  kept beside it for the callers that need to branch on it.
211
+
212
+ A ``hint`` rides with the prose. The /v2 404 for a symbol nothing
213
+ resolves carries what to send instead -- ``NQ.FUT`` with
214
+ ``stype_in='parent'`` for a bare ``NQ`` -- as its own field so a
215
+ caller branching on the body can tell it from the message; but the
216
+ exception a notebook prints is the message, and advice a client
217
+ drops on the floor is advice nobody gets (the shape
218
+ ``options.chain`` had with ``truncated``).
192
219
  """
193
220
  try:
194
221
  body = response.json()
@@ -198,7 +225,7 @@ class Client:
198
225
  return str(body), None
199
226
  error = body.get("error") or body.get("detail") or body
200
227
  if isinstance(error, dict):
201
- return str(error.get("message") or error), error
228
+ return _prose_with_hint(error), error
202
229
  return str(error), None
203
230
 
204
231
  @classmethod
@@ -73,6 +73,26 @@ STORAGE_TIMEOUT = (10, 120)
73
73
  #: decoded table at full size at once is worth avoiding.
74
74
  STORAGE_SPOOL_BYTES = 32 * 1024 * 1024
75
75
 
76
+ #: Widest window of missing days :meth:`Timeseries.iter_range` fetches
77
+ #: in one request, once it has ramped up to it.
78
+ #:
79
+ #: A cadence, not a capacity. The service's own ceilings decide what
80
+ #: fits and refuse what does not, and :func:`_split_window` already
81
+ #: halves a window that gets refused, so nothing here is protecting
82
+ #: memory. What it buys is the caller seeing frames while the fetch is
83
+ #: still going, which one request for the whole window cannot give
84
+ #: however fast it is: measured on a cold month of niche-equity
85
+ #: ``mbp-10``, 129.5 of the 132.4 seconds elapsed before the first
86
+ #: frame, because one manifest was hydrating nineteen days inside the
87
+ #: request. That is indistinguishable from a hang, and it is the shape
88
+ #: of the "all projects hang, no error" report this exists to answer.
89
+ #:
90
+ #: Five caps a ramp that starts at one day (see :func:`_runs`), so a
91
+ #: cold month is a handful of requests rather than one or nineteen, and
92
+ #: the first of them is small. A warm window never reaches this at
93
+ #: all -- it has no gaps, so it stays the single manifest it always was.
94
+ HYDRATE_CHUNK_DAYS = 5
95
+
76
96
 
77
97
  def _symbols(value: str | Sequence[str]) -> list[str]:
78
98
  return [value] if isinstance(value, str) else [str(v) for v in value]
@@ -147,6 +167,66 @@ def _split_window(start: str, end: str) -> tuple[tuple[str, str], ...] | None:
147
167
  return ((start, middle), (middle, end))
148
168
 
149
169
 
170
+ def _identity(item: dict) -> tuple:
171
+ """What makes two manifest entries the same stored partition.
172
+
173
+ The object key, because it is what the service stores the partition
174
+ under and is therefore stable from one manifest to the next. The
175
+ obvious alternative, ``url``, is a freshly signed URL on every call
176
+ and would make every entry unique -- which is to say it would look
177
+ like it worked and deduplicate nothing.
178
+
179
+ Day and instrument ride along so that a service which omits ``key``
180
+ still distinguishes one instrument-day from another, rather than
181
+ collapsing a whole window onto a single ``None``.
182
+ """
183
+ return (item.get("key"), item["day"], item["instrument_id"])
184
+
185
+
186
+ def _runs(days: Sequence[str], size: int) -> Iterator[tuple[str, str]]:
187
+ """Windows covering ``days``, small at first and then up to ``size``.
188
+
189
+ Consecutive days are grouped, because a run of missing days is one
190
+ fetch rather than several: the days between two gaps are already
191
+ cached and asking for them again would re-fetch what the caller
192
+ has. Groups are then cut into windows, because a run can be a
193
+ whole month and that is the silence being removed.
194
+
195
+ **The first window is one day, and each is twice the last up to**
196
+ ``size``. A flat cut makes the first frame wait for a full chunk,
197
+ which measured 29.0s on a cold 8-day window of equity depth -- an
198
+ improvement on the 58.0s it replaced and still long enough to read
199
+ as nothing happening. Ramping puts the first frame after a single
200
+ day, and by the time the caller is watching output arrive the
201
+ windows are full width, so the round trips are spent where they buy
202
+ reassurance rather than spread over the whole fetch.
203
+
204
+ Yields half-open ``(start, end)`` pairs, ``end`` being the midnight
205
+ after the window's last day. Day-aligned for :func:`_split_window`'s
206
+ reason -- a partition is a whole day, so a boundary inside one would
207
+ put that day in both windows and return its rows twice.
208
+ """
209
+ ordered = sorted({date.fromisoformat(day) for day in days})
210
+ groups: list[list[date]] = []
211
+ for day in ordered:
212
+ if groups and day == groups[-1][-1] + timedelta(days=1):
213
+ groups[-1].append(day)
214
+ else:
215
+ groups.append([day])
216
+
217
+ # Carried ACROSS groups, not reset per group: the ramp is about how
218
+ # long the caller has been waiting, and a second group is reached
219
+ # only after the first has already produced frames.
220
+ width = 1
221
+ for group in groups:
222
+ index = 0
223
+ while index < len(group):
224
+ piece = group[index : index + width]
225
+ index += width
226
+ width = min(width * 2, size)
227
+ yield piece[0].isoformat(), (piece[-1] + timedelta(days=1)).isoformat()
228
+
229
+
150
230
  def _table_from_arrow(payload: bytes) -> pa.Table:
151
231
  with ipc.open_stream(io.BytesIO(payload)) as reader:
152
232
  return reader.read_all()
@@ -358,8 +438,13 @@ class Timeseries(_Namespace):
358
438
  For ranges that do not fit in memory. Each yielded frame is one
359
439
  (day, instrument) partition, in chronological order.
360
440
 
361
- Asks for the window in as few pieces as the service will allow,
362
- which is one piece whenever the days are already cached.
441
+ **Streams the fetch, not only the answer.** The first call asks
442
+ the manifest not to hydrate, so it returns in about a second
443
+ however cold the window is and says which days it does not have.
444
+ Cached days are then yielded straight away and the missing ones
445
+ fetched in runs of :data:`HYDRATE_CHUNK_DAYS` as the iteration
446
+ reaches them. A warm window is still one round trip; a cold one
447
+ produces frames throughout instead of after a single silence.
363
448
  """
364
449
  if kwargs.get("roll_adjust", "none") != "none" or kwargs.get("resample"):
365
450
  raise ValidationError(
@@ -374,18 +459,17 @@ class Timeseries(_Namespace):
374
459
  # day, instrument, projection), so the whole-window body is the right
375
460
  # thing to hand every piece.
376
461
  manifest_body = _manifest_body(columns=columns, **kwargs)
377
- for payload in self._manifests(manifest_body):
378
- for item in payload["files"]:
379
- frame = _labelled(
380
- _partition(manifest_body, item, columns), item, columns
381
- )
382
- frame.attrs["quantpad"] = {
383
- **_manifest_attrs(payload),
384
- "day": item["day"],
385
- "instrument_id": item["instrument_id"],
386
- "symbol": item.get("symbol"),
387
- }
388
- yield frame
462
+ for payload, item in self._partitions(manifest_body):
463
+ frame = _labelled(
464
+ _partition(manifest_body, item, columns), item, columns
465
+ )
466
+ frame.attrs["quantpad"] = {
467
+ **_manifest_attrs(payload),
468
+ "day": item["day"],
469
+ "instrument_id": item["instrument_id"],
470
+ "symbol": item.get("symbol"),
471
+ }
472
+ yield frame
389
473
 
390
474
  def iter_range_rth(self, **kwargs) -> Iterator[pd.DataFrame]:
391
475
  """:meth:`iter_range`, restricted to XNYS regular trading hours.
@@ -499,7 +583,103 @@ class Timeseries(_Namespace):
499
583
  exc.detail,
500
584
  )
501
585
 
502
- def _manifests(self, manifest_body: dict) -> Iterator[dict]:
586
+ def _partitions(self, manifest_body: dict) -> Iterator[tuple[dict, dict]]:
587
+ """``(manifest, file)`` pairs in day order, fetching as it goes.
588
+
589
+ The window is surveyed once without hydrating -- cheap whatever
590
+ the cache holds, measured at 0.9s against 29.6s for the same
591
+ cold window fetched -- and the survey says which days are
592
+ missing. Days already present are handed over immediately;
593
+ missing ones are fetched only when the walk reaches them, so a
594
+ caller whose window is half warm sees frames before any fetch
595
+ starts at all.
596
+
597
+ **Chronological across the join**, which is the constraint that
598
+ shapes this: yielding every cached day and then every fetched
599
+ one would be simpler and would reorder the window, and
600
+ ``iter_range`` promises day order. So the two are interleaved
601
+ by day rather than concatenated.
602
+
603
+ **A calendar day is not the unit of cache state**, which is the
604
+ other constraint and much the less obvious one. A manifest
605
+ speaks in instrument-days, so asking for two symbols whose
606
+ histories were fetched at different times puts one date in
607
+ ``files`` for one symbol and in ``gaps`` for the other. The
608
+ fetch for that date then answers with BOTH instruments -- it is
609
+ given a window, not a list of holes -- so the already-cached one
610
+ arrives a second time, and a caller concatenating the frames
611
+ would silently double those rows. Hence :func:`_identity`: the
612
+ day decides WHEN a partition is handed over, the partition
613
+ itself decides whether it already has been.
614
+
615
+ Degrades rather than breaks against a service that predates the
616
+ flag: an older manifest ignores ``hydrate`` (the request models
617
+ do not forbid unknown fields, so it is dropped rather than
618
+ rejected), fetches anyway and answers with no gaps, which this
619
+ reads as a fully cached window and streams directly. Slower,
620
+ never wrong -- and it keeps the splitting it would otherwise
621
+ have lost, since the survey goes through :meth:`_manifests`
622
+ rather than asking once. Against a service that honours the
623
+ flag that costs nothing: nothing is fetched, so there is no
624
+ hydration ceiling to breach and the splitter loops once.
625
+ """
626
+ surveyed = list(self._manifests(manifest_body, hydrate=False))
627
+
628
+ # No early return for the all-cached case, though one reads
629
+ # naturally here and was written first. The walk below already
630
+ # handles it in one round trip -- there is nothing to fetch, so
631
+ # nothing is fetched -- and it additionally puts the days in
632
+ # order, where handing back ``survey["files"]`` would inherit
633
+ # whatever order the service happened to list them in. A
634
+ # mutation battery is what established the two were equivalent:
635
+ # the shortcut turned out to be the weaker of the two.
636
+ ready: dict[str, list[tuple[dict, dict]]] = {}
637
+ missing: set[str] = set()
638
+ for payload in surveyed:
639
+ for item in payload["files"]:
640
+ ready.setdefault(item["day"], []).append((payload, item))
641
+ for gap in payload.get("gaps") or ():
642
+ missing.add(gap["day"])
643
+
644
+ seen: set[tuple] = set()
645
+
646
+ def fresh(pairs) -> Iterator[tuple[dict, dict]]:
647
+ for payload, item in pairs:
648
+ identity = _identity(item)
649
+ if identity not in seen:
650
+ seen.add(identity)
651
+ yield payload, item
652
+
653
+ # One cursor over the union of both sets, so a fetched day lands
654
+ # between the cached days that surround it.
655
+ fetched: dict[str, list[tuple[dict, dict]]] = {}
656
+ pending = list(_runs(sorted(missing), HYDRATE_CHUNK_DAYS))
657
+ for day in sorted(set(ready) | missing):
658
+ while pending and pending[0][0] <= day:
659
+ start, end = pending.pop(0)
660
+ for payload in self._manifests(
661
+ {**manifest_body, "start": start, "end": end}
662
+ ):
663
+ for item in payload["files"]:
664
+ fetched.setdefault(item["day"], []).append((payload, item))
665
+ yield from fresh(ready.get(day, ()))
666
+ # A missing day that came back with nothing -- a holiday, or
667
+ # a genuinely quiet session -- simply has no entry here, and
668
+ # is skipped exactly as an ``empty`` day always was.
669
+ yield from fresh(fetched.pop(day, ()))
670
+
671
+ # Anything a fetch returned for a day the survey did not list.
672
+ # It should be empty, and it is drained rather than asserted
673
+ # about, because the two calls resolve symbols independently and
674
+ # the failure of the cursor to reach a day would otherwise drop
675
+ # its rows in silence -- the one outcome worth ruling out by
676
+ # construction.
677
+ for day in sorted(fetched):
678
+ yield from fresh(fetched[day])
679
+
680
+ def _manifests(
681
+ self, manifest_body: dict, *, hydrate: bool = True
682
+ ) -> Iterator[dict]:
503
683
  """Manifests covering the window, splitting it when the service says to.
504
684
 
505
685
  The manifest hydrates its missing days synchronously, under its own
@@ -516,6 +696,13 @@ class Timeseries(_Namespace):
516
696
  a property of the data rather than of the calendar -- a month of
517
697
  options trades and a month of equity depth differ by orders of
518
698
  magnitude -- and because the warm case must stay one round trip.
699
+
700
+ ``hydrate`` rides through the split rather than being applied by
701
+ the caller afterwards, so a survey that has to be halved stays a
702
+ survey. Only a service predating the flag can put it in that
703
+ position -- one that honours it fetches nothing and so cannot
704
+ refuse on a hydration ceiling -- but that is exactly the service
705
+ whose refusal this exists to absorb.
519
706
  """
520
707
  pending = [(manifest_body["start"], manifest_body["end"])]
521
708
  while pending:
@@ -523,7 +710,12 @@ class Timeseries(_Namespace):
523
710
  try:
524
711
  payload = self._client._request(
525
712
  "POST", "/v2/timeseries/manifest",
526
- json={**manifest_body, "start": start, "end": end},
713
+ json={
714
+ **manifest_body,
715
+ "start": start,
716
+ "end": end,
717
+ "hydrate": hydrate,
718
+ },
527
719
  timeout=V2_DATA_TIMEOUT,
528
720
  ).json()
529
721
  except ResponseTooLargeError as exc:
@@ -0,0 +1,71 @@
1
+ """A structured refusal's ``hint`` reaches the exception text.
2
+
3
+ The /v2 404 for a symbol nothing resolves carries what to send instead as
4
+ its own ``hint`` field -- ``NQ.FUT with stype_in='parent'`` for a bare
5
+ ``NQ`` -- so a caller reading the body can tell the refusal from the
6
+ advice. The client used to raise ``message`` alone, which is the shape
7
+ ``options.chain`` had with ``truncated``: the server said the useful thing
8
+ and the SDK dropped it before anyone could read it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ import pytest
16
+ import requests
17
+
18
+ from quantpad_data.client import Client
19
+ from quantpad_data.errors import NotFoundError
20
+
21
+ MESSAGE = "None of the requested symbols resolve to an instrument on GLBX.MDP3 over this window."
22
+ HINT = "For NQ try NQ.FUT with stype_in='parent' (every contract in the chain)."
23
+
24
+
25
+ def _response(body: dict, status: int = 404) -> requests.Response:
26
+ response = requests.Response()
27
+ response.status_code = status
28
+ response.url = "https://example.test/v2/timeseries/get_range"
29
+ response._content = json.dumps({"error": body}).encode()
30
+ response.headers["Content-Type"] = "application/json"
31
+ return response
32
+
33
+
34
+ def _client() -> Client:
35
+ return Client(api_key="k", base_url="https://example.test")
36
+
37
+
38
+ class TestTheHintIsRaisedWithTheMessage:
39
+ def test_the_exception_text_carries_both_sentences_in_order(self):
40
+ with pytest.raises(NotFoundError) as caught:
41
+ _client()._raise_for_status(
42
+ _response({"message": MESSAGE, "hint": HINT, "not_found": ["NQ"]}),
43
+ "/v2/timeseries/get_range",
44
+ )
45
+ text = str(caught.value)
46
+ assert text == f"{MESSAGE} {HINT}"
47
+
48
+ def test_the_structured_body_is_still_available_beside_it(self):
49
+ """Branching callers read the fields; the hint joining the prose
50
+ must not take the fields away."""
51
+ message, detail = Client._envelope(
52
+ _response({"message": MESSAGE, "hint": HINT, "not_found": ["NQ"]})
53
+ )
54
+ assert detail["hint"] == HINT
55
+ assert detail["not_found"] == ["NQ"]
56
+ assert message.startswith(MESSAGE)
57
+
58
+ def test_a_refusal_without_a_hint_reads_exactly_as_before(self):
59
+ message, _ = Client._envelope(_response({"message": MESSAGE}))
60
+ assert message == MESSAGE
61
+
62
+ @pytest.mark.parametrize("hint", ["", " ", None, 7, ["not", "prose"]])
63
+ def test_a_blank_or_non_prose_hint_adds_nothing(self, hint):
64
+ message, _ = Client._envelope(_response({"message": MESSAGE, "hint": hint}))
65
+ assert message == MESSAGE
66
+
67
+ def test_a_hint_with_no_message_does_not_become_the_message(self):
68
+ """A body with advice but no refusal is malformed; stringify it
69
+ whole as the client always has rather than promote the hint."""
70
+ message, _ = Client._envelope(_response({"hint": HINT}))
71
+ assert message == str({"hint": HINT})
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import io
14
14
  import json
15
+ from datetime import date, timedelta
15
16
 
16
17
  import pandas as pd
17
18
  import pyarrow as pa
@@ -405,6 +406,392 @@ class TestBulkDelivery:
405
406
  assert len(frames) == 2
406
407
  assert frames[0].attrs["quantpad"]["day"] == "2026-01-05"
407
408
 
409
+ def test_a_warm_window_is_still_one_round_trip(self, monkeypatch):
410
+ """The survey is free when there is nothing to survey.
411
+
412
+ Asking not to hydrate costs an extra call only if it then has to
413
+ fetch. A window whose days are all present must not pay one.
414
+ """
415
+ api, session = client_with([json_response(self._manifest("https://r2.test/a.parquet"))])
416
+ monkeypatch.setattr(
417
+ "quantpad_data.v2._read_parquet_url",
418
+ lambda url, columns: pd.DataFrame({"url": [url]}),
419
+ )
420
+ list(api.timeseries.iter_range(
421
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
422
+ start="2026-01-05", end="2026-01-06",
423
+ ))
424
+ assert len(session.calls) == 1
425
+ assert session.calls[0][2]["json"]["hydrate"] is False
426
+
427
+ def test_the_first_manifest_asks_not_to_fetch(self, monkeypatch):
428
+ """The whole point: the opening call must not hydrate.
429
+
430
+ A cold month spent 129.5 of its 132.4 seconds inside this one
431
+ call, which is what made it read as a hang.
432
+ """
433
+ api, session = client_with([json_response(self._manifest("https://r2.test/a.parquet"))])
434
+ monkeypatch.setattr(
435
+ "quantpad_data.v2._read_parquet_url",
436
+ lambda url, columns: pd.DataFrame({"url": [url]}),
437
+ )
438
+ list(api.timeseries.iter_range(
439
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
440
+ start="2026-01-05", end="2026-01-06",
441
+ ))
442
+ assert session.calls[0][2]["json"]["hydrate"] is False
443
+
444
+ def _survey(self, cached_days, gap_days):
445
+ return {
446
+ "format": "parquet",
447
+ "files": [
448
+ {"day": day, "instrument_id": 42, "symbol": "ESZ6",
449
+ "key": day, "size_bytes": 10, "url": f"https://r2.test/{day}"}
450
+ for day in cached_days
451
+ ],
452
+ "gaps": [{"day": day, "instrument_id": 42, "symbol": "ESZ6"}
453
+ for day in gap_days],
454
+ "metadata": {"dataset": "GLBX.MDP3"},
455
+ }
456
+
457
+ def test_a_cached_day_arrives_before_any_fetch_happens(self, monkeypatch):
458
+ """The latency claim, expressed as ordering.
459
+
460
+ A half-warm window must hand over what it has before it goes and
461
+ gets what it does not -- that is the difference between a first
462
+ frame in a second and a first frame in two minutes.
463
+ """
464
+ api, session = client_with([
465
+ json_response(self._survey(["2026-01-05"], ["2026-01-06"])),
466
+ json_response(self._survey(["2026-01-06"], [])),
467
+ ])
468
+ monkeypatch.setattr(
469
+ "quantpad_data.v2._read_parquet_url",
470
+ lambda url, columns: pd.DataFrame({"url": [url]}),
471
+ )
472
+ frames = api.timeseries.iter_range(
473
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
474
+ start="2026-01-05", end="2026-01-07",
475
+ )
476
+ first = next(frames)
477
+ 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
481
+ rest = list(frames)
482
+ assert [f.attrs["quantpad"]["day"] for f in rest] == ["2026-01-06"]
483
+ assert len(session.calls) == 2
484
+
485
+ def test_fetched_days_keep_their_place_in_the_window(self, monkeypatch):
486
+ """Chronological ACROSS the join, not cached-then-fetched.
487
+
488
+ Yielding everything cached and then everything fetched would be
489
+ the simpler loop and would silently reorder the window, which
490
+ iter_range's contract does not allow.
491
+ """
492
+ api, _ = client_with([
493
+ json_response(self._survey(["2026-01-05", "2026-01-07"], ["2026-01-06"])),
494
+ json_response(self._survey(["2026-01-06"], [])),
495
+ ])
496
+ monkeypatch.setattr(
497
+ "quantpad_data.v2._read_parquet_url",
498
+ lambda url, columns: pd.DataFrame({"url": [url]}),
499
+ )
500
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
501
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
502
+ start="2026-01-05", end="2026-01-08",
503
+ )]
504
+ assert days == ["2026-01-05", "2026-01-06", "2026-01-07"]
505
+
506
+ def test_missing_days_are_fetched_in_a_widening_ramp(self, monkeypatch):
507
+ """A cold run starts with one day and widens to the cap.
508
+
509
+ A flat chunk makes the first frame wait for a whole chunk, which
510
+ measured 29.0s on a cold eight-day window of equity depth. The
511
+ ramp puts the first frame after a single day and is at full
512
+ width by the time anyone is watching frames arrive.
513
+ """
514
+ gaps = [f"2026-01-{day:02d}" for day in range(5, 17)] # 12 days
515
+ widths = [1, 2, 4, 5]
516
+ pieces, at = [], 0
517
+ for width in widths:
518
+ pieces.append(gaps[at:at + width])
519
+ at += width
520
+ api, session = client_with(
521
+ [json_response(self._survey([], gaps))]
522
+ + [json_response(self._survey(piece, [])) for piece in pieces]
523
+ )
524
+ monkeypatch.setattr(
525
+ "quantpad_data.v2._read_parquet_url",
526
+ lambda url, columns: pd.DataFrame({"url": [url]}),
527
+ )
528
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
529
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
530
+ start="2026-01-05", end="2026-01-17",
531
+ )]
532
+ assert days == gaps
533
+ asked = [(c[2]["json"]["start"], c[2]["json"]["end"])
534
+ for c in session.calls[1:]]
535
+ assert asked == [
536
+ ("2026-01-05", "2026-01-06"), # one day, so a frame arrives
537
+ ("2026-01-06", "2026-01-08"), # two
538
+ ("2026-01-08", "2026-01-12"), # four
539
+ ("2026-01-12", "2026-01-17"), # capped at HYDRATE_CHUNK_DAYS
540
+ ]
541
+
542
+ def test_the_ramp_stops_widening_at_the_cap(self, monkeypatch):
543
+ """Doubling without a ceiling ends where it started: one request.
544
+
545
+ Needs a long window to show at all. Over twelve days the capped
546
+ and uncapped ramps happen to agree, because the remainder after
547
+ 1+2+4 is exactly the cap -- so the first version of this test
548
+ passed against an unbounded ramp and proved nothing.
549
+ """
550
+ gaps = [f"2026-01-{day:02d}" for day in range(5, 25)] # 20 days
551
+ api, session = client_with(
552
+ [json_response(self._survey([], gaps))]
553
+ + [json_response(self._survey([], [])) for _ in range(12)]
554
+ )
555
+ monkeypatch.setattr(
556
+ "quantpad_data.v2._read_parquet_url",
557
+ lambda url, columns: pd.DataFrame({"url": [url]}),
558
+ )
559
+ list(api.timeseries.iter_range(
560
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
561
+ start="2026-01-05", end="2026-01-25",
562
+ ))
563
+ widths = [
564
+ (date.fromisoformat(c[2]["json"]["end"])
565
+ - date.fromisoformat(c[2]["json"]["start"])).days
566
+ for c in session.calls[1:]
567
+ ]
568
+ from quantpad_data import v2 as v2_module
569
+
570
+ assert widths == [1, 2, 4, 5, 5, 3]
571
+ assert max(widths) == v2_module.HYDRATE_CHUNK_DAYS
572
+
573
+ def test_the_ramp_does_not_restart_for_a_later_group_of_gaps(
574
+ self, monkeypatch
575
+ ):
576
+ """The ramp tracks how long the caller has waited, not the group.
577
+
578
+ Restarting it per run of consecutive days would drop back to
579
+ one-day requests deep into a window whose frames are already
580
+ flowing, spending round trips where they buy nothing.
581
+ """
582
+ gaps = ["2026-01-05", "2026-01-06", "2026-01-09", "2026-01-10",
583
+ "2026-01-11"]
584
+ api, session = client_with([
585
+ json_response(self._survey(["2026-01-07"], gaps)),
586
+ json_response(self._survey(["2026-01-05"], [])),
587
+ json_response(self._survey(["2026-01-06"], [])),
588
+ json_response(self._survey(["2026-01-09", "2026-01-10",
589
+ "2026-01-11"], [])),
590
+ ])
591
+ monkeypatch.setattr(
592
+ "quantpad_data.v2._read_parquet_url",
593
+ lambda url, columns: pd.DataFrame({"url": [url]}),
594
+ )
595
+ list(api.timeseries.iter_range(
596
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
597
+ start="2026-01-05", end="2026-01-12",
598
+ ))
599
+ asked = [(c[2]["json"]["start"], c[2]["json"]["end"])
600
+ for c in session.calls[1:]]
601
+ # 1 then 2 within the first group; the second group continues at
602
+ # 4 rather than dropping back to 1.
603
+ assert asked == [
604
+ ("2026-01-05", "2026-01-06"),
605
+ ("2026-01-06", "2026-01-07"),
606
+ ("2026-01-09", "2026-01-12"),
607
+ ]
608
+
609
+ def test_cached_days_between_two_gaps_are_not_asked_for_again(
610
+ self, monkeypatch
611
+ ):
612
+ """A gap run stops at the first day already held.
613
+
614
+ Asking for one window spanning every missing day is the obvious
615
+ simplification and it drags the cached days in the middle back
616
+ through the service, which is the cost the survey just avoided.
617
+ """
618
+ api, session = client_with([
619
+ json_response(self._survey(
620
+ cached_days=["2026-01-06", "2026-01-07"],
621
+ gap_days=["2026-01-05", "2026-01-08"],
622
+ )),
623
+ json_response(self._survey(["2026-01-05"], [])),
624
+ json_response(self._survey(["2026-01-08"], [])),
625
+ ])
626
+ monkeypatch.setattr(
627
+ "quantpad_data.v2._read_parquet_url",
628
+ lambda url, columns: pd.DataFrame({"url": [url]}),
629
+ )
630
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
631
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
632
+ start="2026-01-05", end="2026-01-09",
633
+ )]
634
+ assert days == ["2026-01-05", "2026-01-06", "2026-01-07", "2026-01-08"]
635
+ asked = [(c[2]["json"]["start"], c[2]["json"]["end"])
636
+ for c in session.calls[1:]]
637
+ assert asked == [("2026-01-05", "2026-01-06"),
638
+ ("2026-01-08", "2026-01-09")]
639
+
640
+ def test_days_are_yielded_in_order_whatever_order_they_are_listed_in(
641
+ self, monkeypatch
642
+ ):
643
+ """Day order is the iterator's promise, not the manifest's.
644
+
645
+ Handing back the manifest's own file list would inherit whatever
646
+ order the service used. Nothing says that is chronological, and
647
+ a caller accumulating a rolling statistic would get it wrong
648
+ without anything failing.
649
+ """
650
+ survey = self._survey(["2026-01-07", "2026-01-05", "2026-01-06"], [])
651
+ api, session = client_with([json_response(survey)])
652
+ monkeypatch.setattr(
653
+ "quantpad_data.v2._read_parquet_url",
654
+ lambda url, columns: pd.DataFrame({"url": [url]}),
655
+ )
656
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
657
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
658
+ start="2026-01-05", end="2026-01-08",
659
+ )]
660
+ assert days == ["2026-01-05", "2026-01-06", "2026-01-07"]
661
+ assert len(session.calls) == 1
662
+
663
+ def test_a_missing_day_that_has_no_data_is_simply_absent(self, monkeypatch):
664
+ """A holiday comes back empty and must not stall the walk."""
665
+ api, _ = client_with([
666
+ json_response(self._survey(["2026-01-05"], ["2026-01-06"])),
667
+ json_response(self._survey([], [])),
668
+ ])
669
+ monkeypatch.setattr(
670
+ "quantpad_data.v2._read_parquet_url",
671
+ lambda url, columns: pd.DataFrame({"url": [url]}),
672
+ )
673
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
674
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
675
+ start="2026-01-05", end="2026-01-07",
676
+ )]
677
+ assert days == ["2026-01-05"]
678
+
679
+ def test_an_older_service_that_ignores_the_flag_still_works(self, monkeypatch):
680
+ """Compatibility, and it is the likely case during a rollout.
681
+
682
+ A service predating the flag hydrates regardless and answers
683
+ with no gaps. That is indistinguishable from a warm window, so
684
+ the walk streams it directly: slower than it could be, never
685
+ wrong.
686
+ """
687
+ api, session = client_with([
688
+ json_response(self._survey(["2026-01-05", "2026-01-06"], []))
689
+ ])
690
+ monkeypatch.setattr(
691
+ "quantpad_data.v2._read_parquet_url",
692
+ lambda url, columns: pd.DataFrame({"url": [url]}),
693
+ )
694
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
695
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
696
+ start="2026-01-05", end="2026-01-07",
697
+ )]
698
+ assert days == ["2026-01-05", "2026-01-06"]
699
+ assert len(session.calls) == 1
700
+
701
+ def test_a_day_no_survey_mentioned_is_still_delivered(self, monkeypatch):
702
+ """Rows must not vanish because two calls disagreed.
703
+
704
+ The cursor walks days the survey named. If a fetch answers for
705
+ a day it did not, the obvious loop drops those rows in silence,
706
+ which is the one outcome worth ruling out by construction.
707
+ """
708
+ api, _ = client_with([
709
+ json_response(self._survey([], ["2026-01-05"])),
710
+ json_response(self._survey(["2026-01-05", "2026-01-09"], [])),
711
+ ])
712
+ monkeypatch.setattr(
713
+ "quantpad_data.v2._read_parquet_url",
714
+ lambda url, columns: pd.DataFrame({"url": [url]}),
715
+ )
716
+ days = [f.attrs["quantpad"]["day"] for f in api.timeseries.iter_range(
717
+ dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
718
+ start="2026-01-05", end="2026-01-10",
719
+ )]
720
+ assert days == ["2026-01-05", "2026-01-09"]
721
+
722
+ def _mixed(self, day, cached_ids, gap_ids):
723
+ """One date, cached for some instruments and missing for others.
724
+
725
+ The state the walk used to get wrong, and it needs no contrivance
726
+ to arrive: two symbols fetched at different times, or a chain
727
+ whose members were not all asked for at once.
728
+ """
729
+ return {
730
+ "format": "parquet",
731
+ "files": [
732
+ {"day": day, "instrument_id": ref, "symbol": f"S{ref}",
733
+ "key": f"{day}/{ref}", "size_bytes": 10,
734
+ "url": f"https://r2.test/{day}/{ref}"}
735
+ for ref in cached_ids
736
+ ],
737
+ "gaps": [{"day": day, "instrument_id": ref, "symbol": f"S{ref}"}
738
+ for ref in gap_ids],
739
+ "metadata": {"dataset": "GLBX.MDP3"},
740
+ }
741
+
742
+ def test_a_day_cached_for_one_symbol_and_missing_for_another(
743
+ self, monkeypatch
744
+ ):
745
+ """Cache state is per instrument-day, not per day.
746
+
747
+ So one date can be in ``files`` for the symbol already fetched
748
+ and in ``gaps`` for the one that was not. The fetch is given a
749
+ WINDOW rather than a list of holes, so it answers with both
750
+ instruments -- and the cached one would be handed over twice,
751
+ which a caller concatenating the frames reads as real rows.
752
+ """
753
+ api, _ = client_with([
754
+ json_response(self._mixed("2026-01-05", [42], [43])),
755
+ json_response(self._mixed("2026-01-05", [42, 43], [])),
756
+ ])
757
+ monkeypatch.setattr(
758
+ "quantpad_data.v2._read_parquet_url",
759
+ lambda url, columns: pd.DataFrame({"url": [url]}),
760
+ )
761
+ refs = [f.attrs["quantpad"]["instrument_id"]
762
+ for f in api.timeseries.iter_range(
763
+ dataset="GLBX.MDP3", symbols=["ESZ6", "ESH7"],
764
+ schema="mbp-10", start="2026-01-05", end="2026-01-06",
765
+ )]
766
+ assert refs == [42, 43]
767
+
768
+ def test_a_partition_is_identified_by_its_key_not_its_url(
769
+ self, monkeypatch
770
+ ):
771
+ """A presigned URL is signed afresh on every call.
772
+
773
+ Deduplicating on it would compile, run, and remove nothing --
774
+ the failure mode worth a test of its own, because the two
775
+ manifests below describe the same stored object and agree on
776
+ everything except the signature.
777
+ """
778
+ first = self._mixed("2026-01-05", [42], [43])
779
+ second = self._mixed("2026-01-05", [42, 43], [])
780
+ second["files"][0]["url"] += "?sig=second"
781
+ api, _ = client_with([json_response(first), json_response(second)])
782
+ monkeypatch.setattr(
783
+ "quantpad_data.v2._read_parquet_url",
784
+ lambda url, columns: pd.DataFrame({"url": [url]}),
785
+ )
786
+ frames = list(api.timeseries.iter_range(
787
+ dataset="GLBX.MDP3", symbols=["ESZ6", "ESH7"], schema="mbp-10",
788
+ start="2026-01-05", end="2026-01-06",
789
+ ))
790
+ assert [f.attrs["quantpad"]["instrument_id"] for f in frames] == [42, 43]
791
+ # The first signature won, i.e. the survey's entry was the one
792
+ # served -- not the refetched duplicate wearing a new URL.
793
+ assert frames[0]["url"][0].endswith("/2026-01-05/42")
794
+
408
795
  def test_a_bulk_frame_is_labelled_like_an_interactive_one(self, monkeypatch):
409
796
  """The bug this pins: storage keys partitions by instrument id and
410
797
  stores no name, so bulk came back a column short. Since
@@ -533,24 +920,100 @@ class TestAWindowTooColdToFetchInOneGo:
533
920
  lambda url, columns: pd.DataFrame({"url": [url]}),
534
921
  )
535
922
 
536
- def test_a_window_refused_for_hydration_is_asked_for_in_halves(
537
- self, monkeypatch
538
- ):
923
+ def _survey(self, gap_days):
924
+ """What the opening, non-hydrating call answers.
925
+
926
+ Since the survey does not fetch, it is not the call that can be
927
+ refused for hydration any more -- the fetch of a run of missing
928
+ days is. So every case below reaches the splitter through one
929
+ of these rather than on the first request, which is where the
930
+ refusal now actually arrives.
931
+ """
932
+ return json_response({
933
+ "format": "parquet",
934
+ "files": [],
935
+ "gaps": [{"day": day, "instrument_id": 42, "symbol": "NVDA"}
936
+ for day in gap_days],
937
+ "metadata": {"dataset": "XNAS.ITCH"},
938
+ })
939
+
940
+ def _days(self, first, count):
941
+ base = date.fromisoformat(first)
942
+ return [(base + timedelta(days=i)).isoformat() for i in range(count)]
943
+
944
+ def _ramped(self, *after):
945
+ """The survey plus the two narrow windows the ramp asks for first.
946
+
947
+ ``iter_range`` opens at one day and widens, and a one-day window
948
+ cannot be halved, so the splitter is not reachable on the first
949
+ fetch any more. These cases therefore let the ramp run up to
950
+ its four-day window and refuse THAT, which is where a real
951
+ hydration refusal would now land.
952
+ """
953
+ return [
954
+ self._survey(self._days("2026-08-20", 8)),
955
+ json_response(self._manifest("d20", day="2026-08-20")),
956
+ json_response(self._manifest("d21", day="2026-08-21")),
957
+ *after,
958
+ ]
959
+
960
+ def _run(self, api):
961
+ return list(api.timeseries.iter_range(
962
+ dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
963
+ start="2026-08-20", end="2026-08-28",
964
+ ))
965
+
966
+ def test_an_older_service_still_gets_its_survey_split(self, monkeypatch):
967
+ """The survey can be refused, but only by a service that ignores it.
968
+
969
+ One that honours ``hydrate=false`` fetches nothing and so has no
970
+ hydration ceiling to breach. One that predates the flag drops
971
+ it and fetches the whole window -- and that is exactly the
972
+ service the splitting was written for, so the survey has to go
973
+ through the splitter rather than ask once. Asking once reads as
974
+ a pure win against today's deployment and quietly withdraws the
975
+ earlier fix everywhere else.
976
+ """
539
977
  api, session = client_with([
540
978
  self._refusal(),
541
- json_response(self._manifest("https://r2.test/a.parquet", day="2026-08-21")),
542
- json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-22")),
979
+ json_response(self._manifest("first", day="2026-08-20")),
980
+ json_response(self._manifest("second", day="2026-08-25")),
543
981
  ])
544
982
  self._reading(monkeypatch)
545
- frames = list(api.timeseries.iter_range(
546
- dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
547
- start="2026-08-20", end="2026-09-17",
548
- ))
549
- assert len(frames) == 2
983
+ frames = self._run(api)
984
+
550
985
  assert self._windows(session) == [
551
- ("2026-08-20", "2026-09-17"),
552
- ("2026-08-20", "2026-09-03"),
553
- ("2026-09-03", "2026-09-17"),
986
+ ("2026-08-20", "2026-08-28"), # refused
987
+ ("2026-08-20", "2026-08-24"), # so halved
988
+ ("2026-08-24", "2026-08-28"),
989
+ ]
990
+ # Both halves are surveys still, not fetches: splitting a survey
991
+ # must not turn it into the hydrating call it was avoiding.
992
+ assert [call[2]["json"]["hydrate"] for call in session.calls] == [
993
+ False, False, False,
994
+ ]
995
+ # An older service reports no gaps, so the whole window reads as
996
+ # cached and both halves' files are streamed in day order.
997
+ assert [f["url"][0] for f in frames] == ["first", "second"]
998
+
999
+ def test_a_window_refused_for_hydration_is_asked_for_in_halves(
1000
+ self, monkeypatch
1001
+ ):
1002
+ api, session = client_with(self._ramped(
1003
+ self._refusal(),
1004
+ json_response(self._manifest("d23", day="2026-08-23")),
1005
+ json_response(self._manifest("d25", day="2026-08-25")),
1006
+ json_response(self._manifest("d27", day="2026-08-27")),
1007
+ ))
1008
+ self._reading(monkeypatch)
1009
+ self._run(api)
1010
+ assert self._windows(session)[1:] == [
1011
+ ("2026-08-20", "2026-08-21"), # ramp: one day
1012
+ ("2026-08-21", "2026-08-23"), # two
1013
+ ("2026-08-23", "2026-08-27"), # four -- refused
1014
+ ("2026-08-23", "2026-08-25"), # so halved
1015
+ ("2026-08-25", "2026-08-27"),
1016
+ ("2026-08-27", "2026-08-28"), # and the tail of the run
554
1017
  ]
555
1018
 
556
1019
  def test_the_halves_meet_exactly_once_so_no_day_is_read_twice(
@@ -562,41 +1025,35 @@ class TestAWindowTooColdToFetchInOneGo:
562
1025
  yielded twice — a silently doubled series, worse than the refusal
563
1026
  the splitting exists to avoid.
564
1027
  """
565
- api, session = client_with([
1028
+ api, session = client_with(self._ramped(
566
1029
  self._refusal(),
567
- json_response(self._manifest("https://r2.test/a.parquet", day="2026-08-21")),
568
- json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-22")),
569
- ])
570
- self._reading(monkeypatch)
571
- list(api.timeseries.iter_range(
572
- dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
573
- start="2026-08-20T09:30", end="2026-09-17T16:00",
1030
+ json_response(self._manifest("d23", day="2026-08-23")),
1031
+ json_response(self._manifest("d25", day="2026-08-25")),
1032
+ json_response(self._manifest("d27", day="2026-08-27")),
574
1033
  ))
575
- served = self._windows(session)[1:]
576
- assert served[0][1] == served[1][0]
577
- # The caller's own edges survive, times and all; only the internal
578
- # boundary is invented.
579
- assert served[0][0] == "2026-08-20T09:30"
580
- assert served[-1][1] == "2026-09-17T16:00"
1034
+ self._reading(monkeypatch)
1035
+ self._run(api)
1036
+ halves = self._windows(session)[4:6]
1037
+ assert halves[0][1] == halves[1][0]
1038
+ # And together they still span exactly the window that was refused.
1039
+ assert (halves[0][0], halves[1][1]) == ("2026-08-23", "2026-08-27")
581
1040
 
582
1041
  def test_a_half_still_too_large_is_split_again(self, monkeypatch):
583
- api, session = client_with([
1042
+ api, session = client_with(self._ramped(
584
1043
  self._refusal(),
585
1044
  self._refusal(),
586
- json_response(self._manifest("https://r2.test/a.parquet", day="2026-08-21")),
587
- json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-22")),
588
- json_response(self._manifest("https://r2.test/c.parquet", day="2026-08-23")),
589
- ])
590
- self._reading(monkeypatch)
591
- frames = list(api.timeseries.iter_range(
592
- dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
593
- start="2026-08-20", end="2026-09-17",
1045
+ json_response(self._manifest("d23", day="2026-08-23")),
1046
+ json_response(self._manifest("d24", day="2026-08-24")),
1047
+ json_response(self._manifest("d25", day="2026-08-25")),
1048
+ json_response(self._manifest("d27", day="2026-08-27")),
594
1049
  ))
595
- assert len(frames) == 3
596
- assert self._windows(session)[-3:] == [
597
- ("2026-08-20", "2026-08-27"),
598
- ("2026-08-27", "2026-09-03"),
599
- ("2026-09-03", "2026-09-17"),
1050
+ self._reading(monkeypatch)
1051
+ self._run(api)
1052
+ assert self._windows(session)[3:7] == [
1053
+ ("2026-08-23", "2026-08-27"), # refused
1054
+ ("2026-08-23", "2026-08-25"), # refused again
1055
+ ("2026-08-23", "2026-08-24"), # so halved once more
1056
+ ("2026-08-24", "2026-08-25"),
600
1057
  ]
601
1058
 
602
1059
  def test_the_pieces_arrive_in_order(self, monkeypatch):
@@ -606,18 +1063,17 @@ class TestAWindowTooColdToFetchInOneGo:
606
1063
  before the remainder of the first, which a caller accumulating a
607
1064
  series or a rolling statistic would silently get wrong.
608
1065
  """
609
- api, _ = client_with([
610
- self._refusal(), self._refusal(),
611
- json_response(self._manifest("first", day="2026-08-21")),
612
- json_response(self._manifest("second", day="2026-08-22")),
613
- json_response(self._manifest("third", day="2026-08-23")),
614
- ])
615
- self._reading(monkeypatch)
616
- frames = list(api.timeseries.iter_range(
617
- dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
618
- start="2026-08-20", end="2026-09-17",
1066
+ api, _ = client_with(self._ramped(
1067
+ self._refusal(),
1068
+ json_response(self._manifest("d23", day="2026-08-23")),
1069
+ json_response(self._manifest("d25", day="2026-08-25")),
1070
+ json_response(self._manifest("d27", day="2026-08-27")),
619
1071
  ))
620
- assert [f["url"][0] for f in frames] == ["first", "second", "third"]
1072
+ self._reading(monkeypatch)
1073
+ frames = self._run(api)
1074
+ assert [f["url"][0] for f in frames] == [
1075
+ "d20", "d21", "d23", "d25", "d27",
1076
+ ]
621
1077
 
622
1078
  def test_a_single_day_that_cannot_be_fetched_is_reported_not_looped(self):
623
1079
  """Nothing smaller can be asked for, so the refusal has to surface.
@@ -625,14 +1081,17 @@ class TestAWindowTooColdToFetchInOneGo:
625
1081
  Halving a one-day window returns the same window, so a splitter
626
1082
  that did not stop here would ask for it for ever.
627
1083
  """
628
- api, session = client_with([self._refusal()])
1084
+ api, session = client_with([
1085
+ self._survey(["2026-08-20"]),
1086
+ self._refusal(),
1087
+ ])
629
1088
  with pytest.raises(ResponseTooLargeError) as excinfo:
630
1089
  list(api.timeseries.iter_range(
631
1090
  dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
632
1091
  start="2026-08-20", end="2026-08-21",
633
1092
  ))
634
1093
  assert excinfo.value.reason == "hydration_limit"
635
- assert len(session.calls) == 1
1094
+ assert len(session.calls) == 2
636
1095
 
637
1096
  def test_a_window_carrying_times_across_two_days_is_still_split(
638
1097
  self, monkeypatch
@@ -643,18 +1102,25 @@ class TestAWindowTooColdToFetchInOneGo:
643
1102
  spans two daily partitions, so there is a midnight between them to
644
1103
  cut at. Reading it as "already one day" refused a window that could
645
1104
  have been served, on the retry path that exists to serve it.
1105
+
1106
+ Asked through ``mode='bulk'`` rather than ``iter_range``: the
1107
+ iterator now fetches missing days in day-aligned runs, so a
1108
+ caller's times never reach the splitter from there. Bulk still
1109
+ hands it the window exactly as written, so this is the path that
1110
+ still has the question — and the reason the time handling in
1111
+ :func:`_split_window` is load-bearing rather than vestigial.
646
1112
  """
647
1113
  api, session = client_with([
648
1114
  self._refusal(),
649
- json_response(self._manifest("https://r2.test/a.parquet", day="2026-08-21")),
650
- json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-22")),
1115
+ json_response(self._manifest("https://r2.test/a.parquet", day="2026-08-20")),
1116
+ json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-21")),
651
1117
  ])
652
1118
  self._reading(monkeypatch)
653
- frames = list(api.timeseries.iter_range(
1119
+ frame = api.timeseries.get_range(
654
1120
  dataset="XNAS.ITCH", symbols="NVDA", schema="mbp-10",
655
- start="2026-08-20T13:00", end="2026-08-21T13:00",
656
- ))
657
- assert len(frames) == 2
1121
+ start="2026-08-20T13:00", end="2026-08-21T13:00", mode="bulk",
1122
+ )
1123
+ assert len(frame) == 2
658
1124
  assert self._windows(session) == [
659
1125
  ("2026-08-20T13:00", "2026-08-21T13:00"), # refused
660
1126
  ("2026-08-20T13:00", "2026-08-21"), # up to the midnight
File without changes
File without changes