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.
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/PKG-INFO +1 -1
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/pyproject.toml +1 -1
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/client.py +29 -2
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/v2.py +208 -16
- quantpad_data-0.7.1/tests/test_refusal_hint.py +71 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2.py +526 -60
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/.gitignore +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/README.md +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/__init__.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/memory.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/conftest.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_client.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_lean.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_packaging.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_rth.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.6.0 → quantpad_data-0.7.1}/tests/test_version.py +0 -0
|
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
|
|
|
84
84
|
"QuantPad support about /v2 access."
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
VERSION = "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
|
|
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
|
-
|
|
362
|
-
|
|
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.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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={
|
|
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
|
|
537
|
-
|
|
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("
|
|
542
|
-
json_response(self._manifest("
|
|
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 =
|
|
546
|
-
|
|
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-
|
|
552
|
-
("2026-08-20", "2026-
|
|
553
|
-
("2026-
|
|
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("
|
|
568
|
-
json_response(self._manifest("
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
assert
|
|
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("
|
|
587
|
-
json_response(self._manifest("
|
|
588
|
-
json_response(self._manifest("
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
("2026-08-
|
|
599
|
-
("2026-
|
|
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(),
|
|
611
|
-
json_response(self._manifest("
|
|
612
|
-
json_response(self._manifest("
|
|
613
|
-
json_response(self._manifest("
|
|
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
|
-
|
|
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([
|
|
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) ==
|
|
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-
|
|
650
|
-
json_response(self._manifest("https://r2.test/b.parquet", day="2026-08-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|