Skip to content

fix(DATA-004): stop re-downloading stocks that listed after the window opened - #114

Open
DoRmAmMu1997 wants to merge 3 commits into
fix/data-003-vendor-dedupefrom
fix/data-004-vendor-earliest-bar
Open

fix(DATA-004): stop re-downloading stocks that listed after the window opened#114
DoRmAmMu1997 wants to merge 3 commits into
fix/data-003-vendor-dedupefrom
fix/data-004-vendor-earliest-bar

Conversation

@DoRmAmMu1997

Copy link
Copy Markdown
Owner

Stacked on #112 (DATA-003), which introduced _cache_covers_range. This PR targets fix/data-003-vendor-dedupe, not main — merge #112 first.

Why

Both cache-coverage checks require first_date <= requested_start, where requested_start is today minus ten years. A stock that listed after that date can never satisfy it, because DhanHQ has nothing earlier to give.

200 of 577 cached symbols were in that state — DMART (listed 2017-03-21), RBLBANK (2016-08-31), LTTS, COHANCE and ~196 more. Every prefetch and every scan re-downloaded their full history, wrote the same short frame back, and did it again next time. Forever.

The distinction that had to be preserved

The two cases look identical from the cached file alone, and conflating them would be a correctness bug:

Case Vendor has earlier data? Correct action
Interrupted prefetch left a partial file yes must still refetch
Stock listed after the window opened no refetching is waste forever

What changed

A new .firstbar sidecar records what the vendor actually served, following the precedent DATA-002 established with .checked (an empty tail) and .repaired (a repair cooldown):

{"requested_from": "2016-08-24", "earliest_available": "2017-03-21", "recorded_on": "2026-08-24"}

A cache then reaches back far enough when it either literally covers the requested start or already begins at the vendor's earliest known bar.

Three properties stop the marker ever hiding genuinely missing history:

  • It only counts when the recorded probe reached at least as far back as the current request. Learning that nothing exists before 2021 when you only asked from 2021 says nothing about 2016, so a shallow probe cannot suppress a deeper refetch.
  • It expires after 30 days (VENDOR_EARLIEST_RECHECK_DAYS), because vendors do occasionally backfill history. One request per affected symbol per month is negligible; a permanent belief is not.
  • Any read or parse failure returns None and the strict rule applies, so a corrupt marker can only ever cost an extra request.

Suppressing the backfill deliberately falls through to the normal freshness and incremental logic rather than returning early — a later listing still needs its daily top-up. The point is to stop the pointless ten-year refetch, not to freeze the symbol. There's a test for exactly that.

Measured on a copy of the real 577-file cache

nifty_500, 500 rows, same data for both sides:

Prefetch Dhan requests Full-window backfills
pass 1 (learns) 500 176
pass 2 176 0
pass 3 0 0
Scan path Cache hits Misses Files rewritten
before (DATA-003) 324 176 176
after 500 0 0

Each of those 176 misses was a full ten-year re-download that also rewrote the parquet — the bulk of the six minutes run_id=4 took.

Gates

1,951 tests pass, coverage 89.82% (floor 87%); ruff, mypy, bandit, compileall, and pre-commit validate-config clean. No dependency changes.

Housekeeping: .firstbar joins .checked and .repaired in cleanup_stale_cache_files, so an orphan is removed with its parquet.

🤖 Generated with Claude Code

…w opened

Both cache-coverage checks require first_date <= requested_start, where
requested_start is today minus ten years. A stock that listed after that date can
never satisfy it, because DhanHQ has nothing earlier to give. 200 of 577 cached
symbols were in that state (DMART listed 2017-03-21, RBLBANK 2016-08-31, LTTS,
COHANCE, ~196 more): every prefetch and every scan re-downloaded their full
history, wrote the same short frame back, and did it again next time.

The two cases look identical from the cached file alone and must not be conflated:
an interrupted prefetch leaves a partial file while the vendor DOES have the
missing years and must be refetched; a later listing means the vendor has nothing
earlier and refetching is waste forever.

Fixed by recording what the vendor actually served, following the sidecar
precedent DATA-002 established with .checked and .repaired. A new .firstbar marker
stores the probe's requested_from, the earliest_available bar that came back, and
recorded_on. A cache is then treated as reaching back far enough when it either
literally covers the requested start or already begins at the vendor's earliest
known bar.

Three properties keep the marker from ever hiding real missing history:

- it only counts when the recorded probe reached at least as far back as the
  current request, so a five-year probe cannot suppress a ten-year refetch;
- it expires after VENDOR_EARLIEST_RECHECK_DAYS (30), because vendors do
  occasionally backfill history;
- any read or parse failure returns None and the strict rule applies, so a corrupt
  marker can only cost an extra request.

Suppressing the backfill deliberately falls through to the normal freshness and
incremental logic rather than returning early: a later listing still needs its
daily top-up, and the point is to stop the pointless ten-year refetch, not to
freeze the symbol.

Measured on a copy of the real 577-file cache, nifty_500 (500 rows):

  prefetch pass 1  500 requests, 176 full-window backfills   (learns)
  prefetch pass 2  176 requests,   0 full-window backfills
  prefetch pass 3    0 requests,   0 full-window backfills
  scan path        before: 324 hits / 176 misses (176 full re-downloads)
                   after:  500 hits /   0 misses, 0 files rewritten

Stacked on DATA-003 (PR #112), which introduced _cache_covers_range and is not yet
merged; this branch targets that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DoRmAmMu1997

Copy link
Copy Markdown
Owner Author

@codex can you review this?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a9e5eff69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +584 to +590
self._record_vendor_earliest(
symbol,
security_id,
requested_from=start_date,
candles=candles,
today=_coerce_date(end_date),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use wall-clock time for marker expiry

When get_daily_history is called with a fixed historical or future end_date, this stores that request boundary as recorded_on rather than the date the vendor was actually queried; _vendor_earliest_for likewise evaluates age against the same request boundary. Repeating a historical request therefore always calculates an age of zero, while a future-dated request can remain fresh for far longer than 30 days, so vendor backfills may be hidden indefinitely behind a partial cache. Record and compare against the actual current date (ideally via an injectable clock) independently of the requested candle range.

Useful? React with 👍 / 👎.

DoRmAmMu1997 and others added 2 commits August 25, 2026 14:40
Addresses Codex P2 on PR #114, and merges the updated DATA-003 base.

get_daily_history stamped recorded_on with the request's own end_date and judged
the marker's age against that same boundary. Two consequences: repeating a
historical request always computed an age of zero, so the 30-day expiry never
fired; and a future-dated end_date kept the marker fresh indefinitely. Either way
a vendor backfill could stay hidden behind a partial cache forever.

DailyDataLoader now takes an injectable today_func, alongside the existing
sleep_func injection, and both _record_vendor_earliest and _vendor_earliest_for
use it. The requested window and the wall clock are now explicitly separate
things: "the date I am asking about" is not "the date it is now", and conflating
them is precisely what caused the bug. ensure_daily_history keeps its today
argument, which describes the data window only.

Merge resolution: _cache_covers_range now carries both kinds of evidence, judged
independently. The front uses vendor_earliest (DATA-004: how far back the vendor's
history goes) and the back uses weekday arithmetic plus the .checked marker
(DATA-003: which recent days the market actually published).

Re-measured on a copy of the real 577-file cache, nifty_500, after the stricter
DATA-003 rule:

  prefetch pass 1  500 requests, 176 backfills
  prefetch pass 2  176 requests,   0 backfills
  prefetch pass 3    0 requests,   0 backfills
  scan path        500 hits / 0 misses / 0 files rewritten
  Codex's case     Tuesday scan with Monday's bar missing -> 0/50 hits, all refetched

Both earlier wins survive the stricter rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DoRmAmMu1997

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in efe240c.

get_daily_history was passing the request's own end_date where a wall clock belongs — both when stamping recorded_on and when judging the marker's age. Exactly as you describe: a repeated historical request always computed an age of zero so the 30-day expiry never fired, and a future-dated end_date kept the marker fresh well beyond it, either way hiding a vendor backfill behind a partial cache indefinitely. ensure_daily_history had it right; this path did not.

DailyDataLoader now takes an injectable today_func, alongside the existing sleep_func injection, and both _record_vendor_earliest and _vendor_earliest_for use it. The two concepts are now explicitly separate in the code and the docstrings: "the date I am asking about" is not "the date it is now", and conflating them is precisely what caused this. ensure_daily_history keeps its today argument, which describes the data window only.

Two regression tests, both failing on the previous commit:

  • a marker recorded through a historical request is stamped from the clock, and is expired when the clock advances 31 days;
  • a future-dated request does not extend a 45-day-old marker's life.

Plus a positive case so the expiry tests cannot pass vacuously.

This branch also merges the updated DATA-003 base, which replaced the blanket four-day tolerance with weekday arithmetic plus the .checked marker. _cache_covers_range now carries both kinds of evidence, judged independently: the front by vendor_earliest (how far back the vendor's history goes) and the back by which recent days the market actually published.

Re-measured on a copy of the real 577-file cache after that stricter rule — both earlier wins survive:

Result
prefetch pass 3 0 requests, 0 backfills
scan path 500 hits / 0 misses / 0 files rewritten

Gates: 1,964 tests, 89.81% coverage, ruff/mypy/bandit/compileall clean.

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