Skip to content

fix(data): chunk the remaining candle-request windows under the venue cap - #271

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/chunk-remaining-candle-windows
Aug 15, 2026
Merged

fix(data): chunk the remaining candle-request windows under the venue cap#271
eaitbrahim merged 1 commit into
mainfrom
fix/chunk-remaining-candle-windows

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

The bug

Coinbase rejects any candle request spanning more than ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"

#269 fixed this in market_feed.poll_once. An independent review then found two more sites with the same defect. Both are fixed here, so every candle-request windowing site in the codebase now agrees.

Both are latent. Neither is firing today, and this PR is preventative rather than a repair of live damage. What makes them worth closing is the failure mode: each fails quietly, and one of them fails quietly forever.

Defect 1 — repair.py cannot heal an interior hole larger than the cap

gaps.detect puts no cap on n_missing, so an interior hole can be arbitrarily large. repair_series asked for the whole hole in one call:

fetched = client.get_candles(
    product, granularity, window.start_ts - step, window.end_ts + step
)

A gap of roughly 349+ candles therefore 400s. Because the per-window except swallows the exception into result.errors and continues, the failure is silent and permanent — every scheduled repair retries the same oversized request, and the only symptom is a series that quietly never repairs.

This is not currently triggering

Measured across the deployment (24 products, ONE_HOUR and ONE_DAY), the largest interior gap is:

product granularity interior gap
ICP-USD ONE_HOUR 15 bars
WLD-USD ONE_HOUR 6
PAXG-USD ONE_HOUR 6
ZEC-USD ONE_HOUR 5

Series with an interior gap ≥ 349, i.e. that repair cannot heal: 0. The worst hole in the entire cache is 15 bars against a ~349 limit.

So the justification is "close a latent trap before it bites", not "heal an existing hole". It still earns its place: an interior gap that large is entirely reachable — a multi-day venue outage, a delisting and relisting, or a product added back after a long absence — and a silent, self-perpetuating failure is precisely the kind that goes unnoticed until much later.

Correcting the record: an earlier draft of this PR claimed the 553-candle ZEC-USD figure from the #269 incident was an interior hole repair could never heal. That was wrong. Those 553 candles were end-of-series staleness — a missing tail, which poll_once/ensure_history handle by catching up forward. It was never an interior gap, so repair was never going to be asked to heal it. 553 survives in this PR only as the oversized-gap size used in the tests.

The fix

The widened window is now paged in MAX_CANDLES_PER_REQUEST-sized chunks via a new _fetch_window_chunked helper, importing the constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed. Each chunk is upserted as it arrives, so a mid-window failure leaves the earlier chunks persisted and the next run resumes further along instead of repeating the whole doomed request.

Semantics preserved exactly (they are load-bearing and commented):

  • The ±step widening applies to the OUTER edges only — venues are inconsistent about endpoint inclusivity. Internal chunk boundaries stay contiguous and are not themselves widened.
  • A window is eligible to be recorded absent at source only if EVERY chunk completed. That is the difference between "the venue lacks this data" and "we failed to ask properly", and the module docstring is explicit that the absence record is an ASSERTION ABOUT AN OBSERVATION. A partial failure must never let a window be recorded absent.
  • The per-window except ... continue stays, so one bad window cannot abort the pass.
  • sleep_fn paces every chunk request, matching history._fill_forward. cli.py passes the real time.sleep, so this is real rate limiting.

repair_series's signature is unchanged (keel/cli.py and tests/data/test_fetch_cli.py depend on it).

Defect 2 — history.py requested 301 candles, not 300

window_end   = min(now_ts,      window_start + MAX_CANDLES_PER_REQUEST * step)
window_start = max(start_floor, window_end   - MAX_CANDLES_PER_REQUEST * step)

An INCLUSIVE range [a, a + N*step] holds N+1 candles. Verified: _fill_forward over a 1000-candle gap produced window sizes [301, 301, 301, 97].

Harmless today (301 < 350) — but history.py says MAX_CANDLES_PER_REQUEST = 300 # conservative under Coinbase's ~350-candle response cap, which openly invites raising it toward 350, at which point this silently requests 351 and reproduces the incident in the backfill path.

Both sites now use (MAX_CANDLES_PER_REQUEST - 1) * step — giving [300, 300, 300, 100] — matching what #269 did in market_feed.py, with a comment on the arithmetic at each site so the -1 never reads as an accident.

Tests (written first, confirmed failing for the right reason)

Reverting only the keel/ changes while keeping the new tests:

FAILED tests/data/test_gap_repair.py::test_a_large_hole_is_fetched_in_capped_chunks
FAILED tests/data/test_gap_repair.py::test_a_large_hole_is_fully_recovered_with_no_duplicates_or_drops
FAILED tests/data/test_gap_repair.py::test_chunk_windows_tile_the_outer_range_without_widening_interior_boundaries
FAILED tests/data/test_gap_repair.py::test_one_bad_chunk_is_not_recorded_absent_and_a_later_window_still_repairs
FAILED tests/data/test_gap_repair.py::test_sleep_fn_is_called_once_per_chunk_not_once_per_window
FAILED tests/data/test_gap_repair.py::test_a_multi_chunk_window_with_nothing_at_venue_is_still_recorded_absent
FAILED tests/data/test_history.py::test_fill_forward_never_requests_more_than_the_cap_per_call
FAILED tests/data/test_history.py::test_fill_backward_never_requests_more_than_the_cap_per_call
FAILED tests/data/test_history.py::test_window_sizing_tracks_the_cap_constant_so_raising_it_toward_350_stays_safe
9 failed, 24 passed in 0.78s

Every failure is a real assertion about candle counts, call counts or absence records — assert 351 <= 350 where 351 = max([351, 351, 298]), assert sizes == [300,300,300,100] vs actual [301,301,301,97], assert len(venue.calls) == 2 vs actual 1 — not an import, fixture or typo error. The tenth new test (test_a_small_hole_still_takes_exactly_one_request) is a regression guard that passes both before and after, by design.

The repair tests drive an oversized (553-candle) gap against a _CappedVenue fake that enforces the true ~349 ceiling, so the fake actually returns the 400 the real venue would. They assert on the actual start/end args it received (chunks of exactly [300, 255]), that the chunks tile the outer range with no duplicate and no gap at a boundary, that all 553 rows land, that a failed chunk is not recorded absent while a later window in the same pass still repairs, and that a fully-completed multi-chunk window with nothing at the venue is still recorded absent.

The history tests assert exact window sizes and express the invariant in terms of the constant — one monkeypatches MAX_CANDLES_PER_REQUEST to 350 and asserts no request exceeds it — which is what makes raising the constant safe.

Gates

uv run ruff check keel tests packages   All checks passed!
uv run mypy                             Success: no issues found in 224 source files
uv run pytest -q                        2737 passed, 1 skipped in 31.11s

Baseline at 29d9b20 measured directly: 2727 passed, 1 skipped, plus the 10 added here.

Note this branch is cut from main, independent of #269 — they touch disjoint files (market_feed.py vs repair.py/history.py) and do not conflict.

🤖 Generated with Claude Code

@eaitbrahim
eaitbrahim force-pushed the fix/chunk-remaining-candle-windows branch from a38db87 to 3565f14 Compare August 15, 2026 20:06
… cap

Coinbase rejects any candle request spanning more than ~350 candles with
`400 INVALID_ARGUMENT ... "number of candles requested should be less than
350"`. #269 fixed this in `market_feed.poll_once`; review found two more
sites with the same defect. Both are fixed here so every candle-request
windowing site in the codebase agrees. Both are LATENT -- neither is firing
today -- and both are the kind that fail quietly once they do.

**`repair.py` cannot heal an interior hole larger than the cap.**
`gaps.detect` puts no cap on `n_missing`, and `repair_series` asked for the
whole hole in one call, so a gap of roughly 349+ candles 400s. Because the
per-window `except` swallows the exception into `result.errors` and moves
on, the failure would be *silent and permanent*: every scheduled repair
retries the same oversized request, and the only symptom is a series that
quietly never repairs.

No cached series triggers this today. Measured across the deployment (24
products, ONE_HOUR and ONE_DAY), the largest interior gap is 15 bars
(ICP-USD, ONE_HOUR) against a ~349 limit, and no series has an interior gap
at or over the cap. So this is closing a latent trap, not healing an
existing hole. It is still worth doing: an interior gap that large is
entirely reachable -- a multi-day venue outage, a delisting and relisting,
or a product added back after a long absence -- and the silent-forever
failure mode is exactly the kind that is not noticed until much later.

(For the avoidance of doubt: the 553-candle ZEC-USD figure from the #269
incident was end-of-series STALENESS, which `poll_once`/`ensure_history`
catch up forward. It was never an interior gap, so `repair` was never asked
to heal it. 553 survives here only as the oversized-gap size in the tests.)

The widened window is now paged in `MAX_CANDLES_PER_REQUEST`-sized chunks
(imported from `keel.data.history` -- a duplicated venue limit would be the
same bug class), each upserted as it arrives so a mid-window failure leaves
the earlier chunks persisted and the next run resumes further along.

Semantics preserved exactly, because they are load-bearing:

- the ±step widening applies to the OUTER edges only (venues disagree about
  endpoint inclusivity); internal chunk boundaries stay contiguous and are
  not themselves widened;
- a window is eligible to be recorded *absent at source* only if EVERY
  chunk completed -- the difference between "the venue lacks this data" and
  "we failed to ask properly", and the module docstring is explicit that
  the absence record is an assertion about an observation;
- the per-window `except ... continue` stays, so one bad window cannot
  abort the pass;
- `sleep_fn` paces every chunk request, matching `history._fill_forward`.

**`history.py`'s off-by-one would activate if the constant were raised.**
An inclusive range `[a, a + N*step]` holds N+1 candles, so `_fill_forward`
and `_fill_backward` requested 301, not 300 -- verified: a 1000-candle gap
produced window sizes [301, 301, 301, 97]. Harmless at 300, but
`history.py` invites raising the constant toward 350, at which point it
would silently request 351 and reproduce the incident in the backfill path.
Both sites now use `(MAX_CANDLES_PER_REQUEST - 1) * step`, matching what
#269 did in `market_feed.py`, with a comment on the arithmetic so the -1
never reads as an accident.

Tests written first and confirmed failing for the right reason: 9 of the 10
new tests fail against the unfixed code on candle-count, call-count and
absence-record assertions (the 10th is a regression guard that a small hole
still takes exactly one request, which must pass before and after). The
repair tests drive an oversized gap against a fake venue that enforces the
true ~349 ceiling, and assert on the actual start/end args it received. The
history tests assert exact window sizes and express the invariant in terms
of the constant, so raising it toward 350 stays safe.

Gates: ruff clean, mypy clean (224 source files), pytest 2737 passed /
1 skipped (2727 + 1 skipped at 29d9b20, plus the 10 added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim force-pushed the fix/chunk-remaining-candle-windows branch from 3565f14 to c26c8d3 Compare August 15, 2026 20:07
@eaitbrahim
eaitbrahim merged commit ad1ea5d into main Aug 15, 2026
1 check failed
@eaitbrahim
eaitbrahim deleted the fix/chunk-remaining-candle-windows branch August 15, 2026 20:13
eaitbrahim added a commit that referenced this pull request Aug 15, 2026
…nt (#274)

Follow-up from the independent review of #271. The rule "a gap window may be
recorded absent-at-source only if EVERY chunk completed" is the most dangerous
thing in `repair.py` to get wrong -- getting it wrong permanently writes off a
hole the venue was never fully asked about -- and the multi-chunk path had no
test that could see a regression in it.

`test_one_bad_chunk_is_not_recorded_absent_and_a_later_window_still_repairs`
fails the SECOND chunk. That means the first chunk lands 299 bars, so the
surviving gap window's key shifts from (BASE+1d, BASE+553d) to
(BASE+300d, BASE+553d). `probed_keys` in `repair.py` matches by EXACT key, so
that window is skipped no matter what `probed_ok` holds: its
`windows_absent_at_source == 0` and `get_gap_probes() == []` assertions are
vacuous in their own setup.

Failing the FIRST chunk instead upserts nothing, so the remaining window keeps
its original key and the gate is genuinely exercised. Verified discriminating:
with `probed_ok.append(window)` added to the failure path, this test FAILS and
the pre-existing single-chunk `test_a_failed_fetch_is_NOT_recorded_as_absent`
alone would not have covered the multi-chunk case.

No production code changes -- the shipped behaviour was already correct. This
only stops a plausible future refactor ("partial progress means the window was
probed") from passing the suite.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 16, 2026
…indow site (#295)

#269 chunked `market_feed.poll_once`; #271 swept for the same defect and fixed
`repair.py` and `history.py`. `market_feed.backfill` was not reached by either,
and has the identical shape: `_missing_ranges` groups absent timestamps into
CONTIGUOUS ranges and each range was requested in a single call, so a hole
wider than the venue's ~350-candle cap 400s exactly as the poll path did. On an
empty repo the entire history window is one such range.

LATENT, and stated plainly rather than dressed up: `backfill` has no production
caller today -- `keel fetch` goes through `history.ensure_history` -- so nothing
is failing on this right now. It is worth closing anyway on #271's own stated
grounds, that every candle-request windowing site in the codebase should agree.
A public data-layer entry point that 400s the moment it is called from anywhere
is a trap left armed for whoever calls it next.

The windowing arithmetic is now in one place, `_request_windows`, used by both
`backfill` and `_poll_catch_up`. That was the actual reason this site was easy
to miss: the `(MAX_CANDLES_PER_REQUEST - 1)` expression existed only inside
`_poll_catch_up`, so `backfill` had nothing to be inconsistent WITH. #271 had
to correct exactly that off-by-one after finding it duplicated in `history.py`
-- the same bug class the shared `MAX_CANDLES_PER_REQUEST` import exists to
prevent -- so it now has one definition here.

`_poll_catch_up` keeps its own filter and `seen` dedup and is otherwise
unchanged; folding it onto the helper also removed a `window_start = window_end
+ gran_sec` line left stranded when its `while` became a `for`.

Tests written first and confirmed failing for the right reason: both cap tests
failed on a single 575-candle request against the unfixed code. The third is a
regression guard that a window within the cap still costs exactly one request,
which passes before and after.

Gates: ruff clean, mypy clean (77 source files), pytest 2762 -> 2765 passed /
1 skipped.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant