quantpad-data 0.8.1__tar.gz → 0.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/PKG-INFO +1 -1
  2. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/pyproject.toml +1 -1
  3. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/__init__.py +2 -1
  4. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/cache.py +21 -3
  5. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/client.py +78 -6
  6. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/errors.py +33 -1
  7. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/v2.py +9 -2
  8. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_client.py +122 -0
  9. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_v2_cache.py +77 -1
  10. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/.gitignore +0 -0
  11. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/README.md +0 -0
  12. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/lean.py +0 -0
  13. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/memory.py +0 -0
  14. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/py.typed +0 -0
  15. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/quantpad_data/rth.py +0 -0
  16. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/conftest.py +0 -0
  17. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_lean.py +0 -0
  18. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_message_encoding.py +0 -0
  19. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_packaging.py +0 -0
  20. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_refusal_hint.py +0 -0
  21. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_rth.py +0 -0
  22. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_v2.py +0 -0
  23. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_v2_gate.py +0 -0
  24. {quantpad_data-0.8.1 → quantpad_data-0.8.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Official Python client for the QuantPad market-data API
5
5
  Project-URL: Documentation, https://api.quantpad.ai/external/docs
6
6
  Author: QuantPad
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.8.1"
7
+ version = "0.8.3"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -11,6 +11,7 @@ import pyarrow as pa
11
11
  from .client import Client, ROLL_ADJUST_MODES, TICK_SCHEMAS, USER_AGENT, VERSION
12
12
  from .errors import (
13
13
  AuthenticationError,
14
+ IncompleteResponseError,
14
15
  NotFoundError,
15
16
  PermissionDeniedError,
16
17
  QuantPadDataError,
@@ -213,7 +214,7 @@ __all__ = [
213
214
  "Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
214
215
  "PermissionDeniedError", "RateLimitError", "QuotaExceededError",
215
216
  "NotFoundError", "V2NotAvailableError", "ValidationError", "UpstreamError",
216
- "ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
217
+ "IncompleteResponseError", "ResponseTooLargeError", "TruncatedChainWarning", "TICK_SCHEMAS",
217
218
  "ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
218
219
  "get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
219
220
  "get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
@@ -349,6 +349,12 @@ def _columns_tag(columns: Sequence[str] | None) -> str:
349
349
  #: would serve the wrong rows.
350
350
  _DELIVERY_ONLY = frozenset({"compression", "max_decoded_bytes"})
351
351
 
352
+ #: Changed whenever the service starts answering an unchanged body with
353
+ #: different rows, so every entry cached under the old answer misses once.
354
+ #: 2: until 2026-10, a window ending mid-day was answered without its last
355
+ #: day, and that short answer was cached here as the window's answer.
356
+ _RANGE_KEY_EPOCH = 2
357
+
352
358
 
353
359
  def _request_key(body: dict) -> str:
354
360
  """A stable key for the answer a request body asks for.
@@ -357,7 +363,9 @@ def _request_key(body: dict) -> str:
357
363
  which is what stops it drifting as ``get_range`` grows arguments.
358
364
  """
359
365
  material = {k: v for k, v in sorted(body.items()) if k not in _DELIVERY_ONLY}
360
- return _hash_key([json.dumps(material, sort_keys=True, default=str)])
366
+ return _hash_key(
367
+ [str(_RANGE_KEY_EPOCH), json.dumps(material, sort_keys=True, default=str)]
368
+ )
361
369
 
362
370
 
363
371
  def range_path(body: dict) -> Path:
@@ -403,13 +411,23 @@ def read_meta(path: Path) -> dict | None:
403
411
  return _read_meta(path)
404
412
 
405
413
 
406
- def get_frame(path: Path) -> pd.DataFrame | None:
414
+ def get_frame(path: Path, *, expect: dict | None = None) -> pd.DataFrame | None:
407
415
  """Read a cached frame, or None on miss.
408
416
 
409
417
  A corrupt or half-written entry is dropped and reported as a miss, so a
410
418
  damaged cache degrades to the network rather than failing the request.
419
+
420
+ ``expect`` names sidecar fields the entry must carry exactly, and a
421
+ mismatch is a miss. This is how a partition the service has since
422
+ re-fetched stops being served from disk: the manifest's ``version``
423
+ moves when upstream corrects a day, and a settled day is otherwise
424
+ never asked about again. An entry written before versions existed
425
+ carries none, so it misses once against a server that sends one.
411
426
  """
412
- if not enabled() or _read_meta(path) is None:
427
+ meta = _read_meta(path) if enabled() else None
428
+ if meta is None:
429
+ return None
430
+ if any(meta.get(field) != value for field, value in (expect or {}).items()):
413
431
  return None
414
432
  try:
415
433
  frame = pd.read_parquet(path)
@@ -10,11 +10,14 @@ from datetime import date
10
10
  from typing import Iterator, Sequence
11
11
 
12
12
  import pandas as pd
13
+ import pyarrow as pa
13
14
  import pyarrow.ipc as ipc
14
15
  import requests
16
+ import urllib3
15
17
 
16
18
  from .errors import (
17
19
  AuthenticationError,
20
+ IncompleteResponseError,
18
21
  NotFoundError,
19
22
  PermissionDeniedError,
20
23
  QuantPadDataError,
@@ -84,7 +87,7 @@ V2_NOT_AVAILABLE = (
84
87
  "QuantPad support about /v2 access."
85
88
  )
86
89
 
87
- VERSION = "0.8.1"
90
+ VERSION = "0.8.3"
88
91
  USER_AGENT = f"quantpad-data-python/{VERSION}"
89
92
  TICK_SCHEMAS = (
90
93
  "trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
@@ -142,6 +145,50 @@ BARS_TIMEOUT = (30, 1800)
142
145
  #: stall detector and comes down to a couple of minutes.
143
146
  V2_DATA_TIMEOUT = (10, 1800)
144
147
 
148
+ #: Backoff between retries: exponential, with jitter, so that the clients
149
+ #: of one outage do not come back in lockstep and knock it over again.
150
+ #:
151
+ #: Was 0.5s, 1s, 2s plus at most 0.25s, with the server's ``Retry-After``
152
+ #: ignored on every 5xx. During the 2026-10-01/02 API-key outage the
153
+ #: service answered every request "503, Retry-After: 5"; this client
154
+ #: retried each call three more times inside four seconds, and one key
155
+ #: kept that up for sixteen hours.
156
+ RETRY_BACKOFF_BASE = 1.0
157
+ RETRY_BACKOFF_MAX = 30.0
158
+
159
+ #: The longest ``Retry-After`` honoured, from any status. Past this the
160
+ #: client would rather fail and let its caller decide than sleep silently:
161
+ #: a daily-quota 429 can say "come back in twenty hours".
162
+ RETRY_AFTER_MAX = 60.0
163
+
164
+
165
+ #: What reading a stream raises when the connection ends early: urllib3's
166
+ #: incomplete-chunk error from the socket, requests' wrapper of the same,
167
+ #: and what pyarrow raises when a message is cut short (ArrowInvalid) or
168
+ #: its read fails (OSError).
169
+ _BROKEN_STREAM = (
170
+ urllib3.exceptions.HTTPError,
171
+ requests.exceptions.RequestException,
172
+ pa.ArrowInvalid,
173
+ OSError,
174
+ )
175
+
176
+
177
+ def _retry_delay(attempt: int, retry_after: float | None) -> float:
178
+ """Seconds to wait before retry number ``attempt + 1``.
179
+
180
+ Exponential with equal jitter -- half the step fixed, half random --
181
+ and never sooner than the server asked, though no later than
182
+ ``RETRY_AFTER_MAX``. The server's figure is jittered too, because
183
+ every client of an outage receives the same one.
184
+ """
185
+ step = min(RETRY_BACKOFF_MAX, RETRY_BACKOFF_BASE * (2**attempt))
186
+ delay = step / 2 + random.uniform(0, step / 2)
187
+ if retry_after is not None and retry_after > 0:
188
+ asked = min(float(retry_after), RETRY_AFTER_MAX)
189
+ delay = max(delay, asked + random.uniform(0, min(1.0, asked / 4)))
190
+ return delay
191
+
145
192
 
146
193
  def _prose_with_hint(error: dict) -> str:
147
194
  """The sentence to raise from a structured refusal.
@@ -269,7 +316,7 @@ class Client:
269
316
  cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
270
317
  raise cls(message, retry_after, status)
271
318
  if status >= 500:
272
- raise UpstreamError(message, status)
319
+ raise UpstreamError(message, status, retry_after=retry_after)
273
320
  raise QuantPadDataError(message, status, detail)
274
321
 
275
322
  def _request(self, method: str, path: str, **kwargs) -> requests.Response:
@@ -322,9 +369,8 @@ class Client:
322
369
  last_error = exc
323
370
  if attempt >= self.max_retries:
324
371
  raise
325
- delay = None
326
- base_delay = delay if delay is not None else min(8.0, 0.5 * (2**attempt))
327
- time.sleep(max(0.0, base_delay) + random.uniform(0, 0.25))
372
+ delay = getattr(exc, "retry_after", None)
373
+ time.sleep(_retry_delay(attempt, delay))
328
374
  raise QuantPadDataError(str(last_error or "Request failed"))
329
375
 
330
376
  @staticmethod
@@ -417,8 +463,34 @@ class Client:
417
463
  response.raw.decode_content = True
418
464
  buffered: list[pd.DataFrame] = []
419
465
  buffered_rows = 0
466
+ received = 0
467
+
468
+ def broken(exc: BaseException) -> IncompleteResponseError:
469
+ return IncompleteResponseError(
470
+ f"The /v1/ticks stream for {params['symbol']} {schema} ended "
471
+ f"before the service finished sending it ({received:,} rows "
472
+ "had arrived). Nothing from this call is complete; retry the "
473
+ f"request. ({type(exc).__name__}: {exc})",
474
+ rows_received=received,
475
+ )
476
+
420
477
  try:
421
- for batch in ipc.open_stream(response.raw):
478
+ # Read errors are told apart from a clean end because the
479
+ # service now ends a failed stream by closing the connection
480
+ # early -- its 200 has already gone out -- and the raw urllib3
481
+ # ProtocolError that surfaces says nothing about what it means.
482
+ try:
483
+ batches = iter(ipc.open_stream(response.raw))
484
+ except _BROKEN_STREAM as exc:
485
+ raise broken(exc) from exc
486
+ while True:
487
+ try:
488
+ batch = next(batches)
489
+ except StopIteration:
490
+ break
491
+ except _BROKEN_STREAM as exc:
492
+ raise broken(exc) from exc
493
+ received += batch.num_rows
422
494
  if not batch.num_rows:
423
495
  continue
424
496
  buffered.append(batch.to_pandas(split_blocks=True, self_destruct=True))
@@ -134,7 +134,39 @@ class ValidationError(ValueError, QuantPadDataError):
134
134
 
135
135
 
136
136
  class UpstreamError(QuantPadDataError):
137
- pass
137
+ """A 5xx from the service.
138
+
139
+ ``retry_after`` is the service's ``Retry-After`` in seconds when it sent
140
+ one: a 503 that says when to come back is a 503 worth believing, and the
141
+ client waits at least that long before retrying it.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ message: str,
147
+ status_code: int | None = None,
148
+ detail: dict | None = None,
149
+ *,
150
+ retry_after: float | None = None,
151
+ ):
152
+ super().__init__(message, status_code, detail)
153
+ self.retry_after = retry_after
154
+
155
+
156
+ class IncompleteResponseError(UpstreamError):
157
+ """A streamed response ended before the service finished sending it.
158
+
159
+ The service now ends a tick stream this way when it fails partway --
160
+ the status was already 200, so the connection is the only signal
161
+ left. Nothing this call returned can be taken as complete; retry the
162
+ request. A subclass of :class:`UpstreamError`, so existing ``except
163
+ UpstreamError`` handlers keep catching it. ``rows_received`` counts
164
+ the rows that had arrived when the stream broke.
165
+ """
166
+
167
+ def __init__(self, message: str, rows_received: int = 0):
168
+ super().__init__(message)
169
+ self.rows_received = rows_received
138
170
 
139
171
 
140
172
  class QuantPadDataNotReady(QuantPadDataError):
@@ -1330,7 +1330,13 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1330
1330
  hostile ``QPD_CACHE_DIR`` can contain, and the only other way it could
1331
1331
  fail is a manifest item missing ``day`` or ``instrument_id``, which the
1332
1332
  caller subscripts for its own labels two lines later regardless.
1333
+
1334
+ A settled day is not immutable: the service re-fetches a day its
1335
+ vendor regenerates, and says so by changing the item's ``version``. A
1336
+ hit therefore has to be the version the manifest describes, or it is
1337
+ the uncorrected copy served from disk for as long as the disk keeps it.
1333
1338
  """
1339
+ expect = {"version": item.get("version")}
1334
1340
  path = None
1335
1341
  if cache.enabled() and cache.day_settled(item.get("day")):
1336
1342
  path = cache.day_path(
@@ -1343,14 +1349,15 @@ def _partition(body: dict, item: dict, columns) -> pd.DataFrame:
1343
1349
  )
1344
1350
 
1345
1351
  if path is not None:
1346
- hit = cache.get_frame(path)
1352
+ hit = cache.get_frame(path, expect=expect)
1347
1353
  if hit is not None:
1348
1354
  return hit
1349
1355
 
1350
1356
  frame = _read_parquet_url(item["url"], columns)
1351
1357
  if path is not None:
1352
1358
  cache.put_frame(
1353
- path, frame, {"day": item["day"], "dataset": body["dataset"]}
1359
+ path, frame,
1360
+ {"day": item["day"], "dataset": body["dataset"], **expect},
1354
1361
  )
1355
1362
  return frame
1356
1363
 
@@ -164,6 +164,128 @@ def test_rate_limit_exposes_retry_after():
164
164
  assert caught.value.retry_after == 7
165
165
 
166
166
 
167
+ def _record_sleeps(monkeypatch) -> list[float]:
168
+ import quantpad_data.client as client_module
169
+
170
+ slept: list[float] = []
171
+ monkeypatch.setattr(client_module.time, "sleep", slept.append)
172
+ return slept
173
+
174
+
175
+ def test_a_503_that_says_when_to_come_back_is_believed(monkeypatch):
176
+ """During the 2026-10-01/02 API-key outage every answer was "503,
177
+ Retry-After: 5", and this client retried each call three more times
178
+ inside four seconds -- one key kept that up for sixteen hours."""
179
+ from quantpad_data.errors import UpstreamError
180
+
181
+ slept = _record_sleeps(monkeypatch)
182
+ unavailable = b'{"error":"API-key validation is temporarily unavailable"}'
183
+ session = FakeSession(
184
+ [response(503, unavailable, {"Retry-After": "5"}) for _ in range(4)]
185
+ )
186
+ client = Client(api_key="secret", session=session, max_retries=3)
187
+
188
+ with pytest.raises(UpstreamError) as caught:
189
+ client.get_coverage("AAPL")
190
+
191
+ assert caught.value.retry_after == 5
192
+ assert len(session.calls) == 4, "retries are capped at max_retries"
193
+ assert len(slept) == 3
194
+ assert all(delay >= 5 for delay in slept), slept
195
+
196
+
197
+ def test_backoff_doubles_with_jitter_when_the_server_names_no_time(monkeypatch):
198
+ from quantpad_data.client import RETRY_BACKOFF_BASE
199
+ from quantpad_data.errors import UpstreamError
200
+
201
+ slept = _record_sleeps(monkeypatch)
202
+ session = FakeSession([response(502, b"bad gateway") for _ in range(4)])
203
+ client = Client(api_key="secret", session=session, max_retries=3)
204
+
205
+ with pytest.raises(UpstreamError):
206
+ client.get_coverage("AAPL")
207
+
208
+ for attempt, delay in enumerate(slept):
209
+ step = RETRY_BACKOFF_BASE * 2**attempt
210
+ assert step / 2 <= delay <= step, (attempt, delay)
211
+
212
+
213
+ def test_jitter_actually_spreads_clients_out():
214
+ from quantpad_data.client import _retry_delay
215
+
216
+ assert len({round(_retry_delay(2, 5), 6) for _ in range(50)}) > 1
217
+ assert len({round(_retry_delay(2, None), 6) for _ in range(50)}) > 1
218
+
219
+
220
+ def test_an_absurd_retry_after_is_capped_rather_than_slept(monkeypatch):
221
+ """A daily-quota 429 can say "come back in twenty hours"; sleeping
222
+ that long without a word is worse than failing."""
223
+ from quantpad_data.client import RETRY_AFTER_MAX, _retry_delay
224
+
225
+ assert _retry_delay(0, 72_000) <= RETRY_AFTER_MAX + 1.0
226
+
227
+
228
+ def _trades_stream(*batch_rows: int, complete: bool) -> bytes:
229
+ """An Arrow IPC tick stream; ``complete=False`` stops before the
230
+ end-of-stream marker, as a body cut off by the connection would."""
231
+ schema = pa.schema([("t", pa.int64()), ("price", pa.float64())])
232
+ sink = io.BytesIO()
233
+ writer = ipc.new_stream(sink, schema)
234
+ for rows in batch_rows:
235
+ writer.write_batch(pa.record_batch(
236
+ [pa.array(range(rows), pa.int64()), pa.array([1.0] * rows)],
237
+ schema=schema,
238
+ ))
239
+ if complete:
240
+ writer.close()
241
+ return sink.getvalue()
242
+
243
+
244
+ class _CutConnection(io.BytesIO):
245
+ """A raw body that ends the way an aborted chunked response does:
246
+ after the bytes that arrived, the next read fails rather than
247
+ returning empty."""
248
+
249
+ def read(self, size=-1):
250
+ data = super().read(size)
251
+ if not data:
252
+ import urllib3
253
+
254
+ raise urllib3.exceptions.ProtocolError(
255
+ "Connection broken: IncompleteRead(0 bytes read)"
256
+ )
257
+ return data
258
+
259
+
260
+ def test_a_tick_stream_cut_short_raises_an_error_that_says_so():
261
+ """The service ends a tick stream that fails partway by closing the
262
+ connection -- its 200 has already gone out. What surfaced before was
263
+ urllib3's bare ProtocolError; a caller has to be told that what they
264
+ received is incomplete and that retrying is the answer."""
265
+ from quantpad_data.errors import IncompleteResponseError, UpstreamError
266
+
267
+ cut = response(200, b"")
268
+ cut.raw = _CutConnection(_trades_stream(4, 6, complete=False))
269
+ client = Client(api_key="secret", session=FakeSession([cut]), max_retries=0)
270
+
271
+ with pytest.raises(IncompleteResponseError) as caught:
272
+ list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
273
+
274
+ assert isinstance(caught.value, UpstreamError), "existing handlers must still catch it"
275
+ assert caught.value.rows_received == 10
276
+ assert "retry" in str(caught.value).lower()
277
+
278
+
279
+ def test_a_complete_tick_stream_is_unaffected():
280
+ whole = response(200, b"")
281
+ whole.raw = io.BytesIO(_trades_stream(4, 6, complete=True))
282
+ client = Client(api_key="secret", session=FakeSession([whole]), max_retries=0)
283
+
284
+ frames = list(client.get_ticks("NQ.FUT", "trades", 1, 2, chunksize=1000))
285
+
286
+ assert sum(len(frame) for frame in frames) == 10
287
+
288
+
167
289
  def test_universe_and_coverage_paths():
168
290
  session = FakeSession(
169
291
  [response(body=b'{"results":[]}'), response(body=b'{"symbol":"AAPL"}')]
@@ -124,12 +124,13 @@ def api(*responses):
124
124
  return Historical(client).timeseries, session
125
125
 
126
126
 
127
- def manifest_for(days, instrument_id=1551):
127
+ def manifest_for(days, instrument_id=1551, version=None):
128
128
  return response(200, json.dumps({
129
129
  "format": "parquet",
130
130
  "files": [
131
131
  {"day": day, "instrument_id": instrument_id, "symbol": "BA",
132
132
  "key": f"k/{day}", "size_bytes": 10,
133
+ **({"version": version} if version is not None else {}),
133
134
  "url": f"http://r2.test/{day}.parquet"}
134
135
  for day in days
135
136
  ],
@@ -276,6 +277,17 @@ class TestWhatTheKeyMustSeparate:
276
277
  base = _body(**ASK)
277
278
  assert cache.range_path(base) == cache.range_path({**base, field: value})
278
279
 
280
+ def test_an_answer_cached_before_the_service_changed_it_is_not_served(
281
+ self, monkeypatch
282
+ ):
283
+ # Until 2026-10 a window ending mid-day came back without its last
284
+ # day, and was cached that way. The body has not changed, so only the
285
+ # epoch can tell the old short answer from the new one.
286
+ body = _body(**{**ASK, "end": "2026-09-16T13:58:00Z"})
287
+ current = cache.range_path(body)
288
+ monkeypatch.setattr(cache, "_RANGE_KEY_EPOCH", cache._RANGE_KEY_EPOCH - 1)
289
+ assert cache.range_path(body) != current
290
+
279
291
 
280
292
  class TestWhatMustNotBeCached:
281
293
  def test_a_window_reaching_into_the_current_day_is_not_cached(self):
@@ -546,6 +558,70 @@ class TestPartitionsAreCachedIndividually:
546
558
  )
547
559
 
548
560
 
561
+ class TestACorrectedDayIsNotServedFromDisk:
562
+ """A settled day is final for the calendar, not for the vendor.
563
+
564
+ The service re-fetches a day its vendor regenerates and changes the
565
+ manifest item's ``version`` when it does. Before this, a day already on
566
+ disk was never asked about again, so the correction reached nobody who
567
+ had read the day once -- and nothing anywhere said the copy was old.
568
+ """
569
+
570
+ CORRECTED = ROWS.assign(price=[100.5, 101.5])
571
+
572
+ def test_the_same_version_is_still_a_hit(self, monkeypatch):
573
+ urls = UrlRecorder()
574
+ monkeypatch.setattr(requests, "get", urls)
575
+ ts, _ = api(
576
+ manifest_for(["2026-09-16"], version="v1"),
577
+ manifest_for(["2026-09-16"], version="v1"),
578
+ )
579
+ list(ts.iter_range(**_manifest_ask()))
580
+ list(ts.iter_range(**_manifest_ask()))
581
+ assert len(urls.urls) == 1
582
+
583
+ def test_a_new_version_is_fetched_and_replaces_the_copy(self, monkeypatch):
584
+ urls = UrlRecorder()
585
+ monkeypatch.setattr(requests, "get", urls)
586
+ ts, _ = api(
587
+ manifest_for(["2026-09-16"], version="v1"),
588
+ manifest_for(["2026-09-16"], version="v2"),
589
+ manifest_for(["2026-09-16"], version="v2"),
590
+ )
591
+ list(ts.iter_range(**_manifest_ask()))
592
+ urls.frame = self.CORRECTED
593
+ [corrected] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
594
+ [again] = [f.copy() for f in ts.iter_range(**_manifest_ask())]
595
+
596
+ assert len(urls.urls) == 2, "fetched once per version, not per read"
597
+ assert list(corrected["price"]) == [100.5, 101.5]
598
+ pd.testing.assert_frame_equal(corrected, again)
599
+
600
+ def test_a_copy_written_before_versions_existed_is_refetched_once(
601
+ self, monkeypatch
602
+ ):
603
+ """Every entry on disk today carries no version, and some of them
604
+ are the uncorrected days this exists for."""
605
+ urls = UrlRecorder()
606
+ monkeypatch.setattr(requests, "get", urls)
607
+ ts, _ = api(
608
+ manifest_for(["2026-09-16"]),
609
+ manifest_for(["2026-09-16"], version="v1"),
610
+ manifest_for(["2026-09-16"], version="v1"),
611
+ )
612
+ for _ in range(3):
613
+ list(ts.iter_range(**_manifest_ask()))
614
+ assert len(urls.urls) == 2
615
+
616
+ def test_a_range_entry_is_not_asked_for_a_version_it_never_had(self):
617
+ """``expect`` is opt-in: the whole-response cache has no per-file
618
+ version to compare, and must keep its hits."""
619
+ path = cache.range_path(_body(**ASK))
620
+ assert cache.put_frame(path, ROWS, {"echo": {}})
621
+ assert cache.get_frame(path) is not None
622
+ assert cache.get_frame(path, expect={"version": "v1"}) is None
623
+
624
+
549
625
  class TestTheSettlementRuleItself:
550
626
  def test_a_past_day_is_settled(self):
551
627
  assert cache.day_settled("2026-09-16") is True
File without changes
File without changes