fix(DATA-004): stop re-downloading stocks that listed after the window opened - #114
Conversation
…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>
|
@codex can you review this? |
There was a problem hiding this comment.
💡 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".
| self._record_vendor_earliest( | ||
| symbol, | ||
| security_id, | ||
| requested_from=start_date, | ||
| candles=candles, | ||
| today=_coerce_date(end_date), | ||
| ) |
There was a problem hiding this comment.
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 👍 / 👎.
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>
|
Confirmed and fixed in
Two regression tests, both failing on the previous commit:
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 Re-measured on a copy of the real 577-file cache after that stricter rule — both earlier wins survive:
Gates: 1,964 tests, 89.81% coverage, ruff/mypy/bandit/compileall clean. |
DoRmAmMu1997
left a comment
There was a problem hiding this comment.
Comment-only review at the current PR head. I found three P2 marker-integrity/lifecycle issues and will address them in the approved follow-up after reconciling the advanced #112 base.
| recorded_on = _coerce_date(str(payload["recorded_on"])) | ||
| except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): | ||
| return None | ||
| if (self.today_func() - recorded_on).days >= VENDOR_EARLIEST_RECHECK_DAYS: |
There was a problem hiding this comment.
[P2] Reject future-dated marker timestamps
The age check rejects only values greater than or equal to 30. A valid JSON marker whose recorded_on is ahead of the injected wall clock has a negative age and is accepted until 30 days after that future date, so incomplete history can remain certified far longer than the intended TTL. I reproduced a 2027 marker being served as fresh under a 2026 clock with zero refetches. Require 0 <= age < VENDOR_EARLIEST_RECHECK_DAYS and test the negative-age case.
| """ | ||
| if first_date <= requested_start: | ||
| return True | ||
| return vendor_earliest is not None and first_date <= vendor_earliest |
There was a problem hiding this comment.
[P2] Bind vendor-earliest evidence to the cache's actual first bar
Using first_date <= vendor_earliest accepts semantically contradictory state. For a 2016 request, a valid-looking marker claiming 2030 currently certifies a cache starting in 2020, hiding the genuinely missing 2016–2020 history. Outside literal start coverage, require the cached first date to equal the marker's earliest bar so inconsistent evidence fails closed to a refetch.
| if first_date is None: | ||
| return | ||
| start = _coerce_date(requested_from) | ||
| if first_date <= start: |
There was a problem hiding this comment.
[P2] Update first-bar evidence monotonically
This return/write policy ignores existing evidence. A qualifying shallow probe overwrites a fresh marker produced by a deeper probe, so the next deep scan pays another full-window backfill; conversely, an equally deep response that now reaches requested_from returns without clearing the obsolete marker. Preserve fresh deeper evidence, replace it only with equally deep/deeper evidence, and remove it when an equally deep/deeper response proves the old limitation is gone.
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Codex <codex@openai.com>
DoRmAmMu1997
left a comment
There was a problem hiding this comment.
Follow-up review complete
Reviewed and remediated at 3e0104f69eeb7020f2d15c5138b7470413dc8f95, relative to the current PR #112 base 6c06a0207ecb17de2b78d8ab08296d7bd2c1e5fa.
The three original review findings are addressed: future-dated evidence is rejected, contradictory first-bar evidence cannot certify the cache, and fresh deeper evidence survives shallow probes without timestamp renewal. Strict canonical-date/chronology parsing, obsolete-marker retirement, a request-bounded test fixture, and the data-acquisition LLD complete the approved plan.
The task review additionally caught invalid UTF-8 sidecar bytes escaping parsing. The follow-up catches that narrow exception and includes a public-loader refetch regression; scoped re-review confirmed it was addressed. Beginner-friendly docstrings and inline comments explain the safety decisions. PR #112's historical defaults, explicit scanner tail authority, seven-day bound, actual-frame recheck, and exact-row incremental deduplication remain intact.
Verification:
- Local clean Python 3.12.14: 2,034 passed, 1 skipped; 89.95% coverage with process-local
LOG_FORMAT=text. - All local static gates and dependency audit passed; focused loader suite 108 passed.
- Hosted run 33878383650: Python 3.11, Python 3.12, Docker image build and Compose smoke test passed; hosted coverage 89.99%.
- Final whole-branch review found no remaining actionable issues.
- Final Codex Security diff scan
5bc2e5f7-e0aa-481c-aac1-a527427dd3a8completed with no vulnerabilities found and full changed-source coverage. This is a scoped review, not a claim of absolute security.
The host's inherited LOG_FORMAT=json exposes an existing logging-test assertion issue; matching CI's development text format makes the whole suite pass without excluding tests or changing unrelated source. No saved environment settings were changed.
Only fresh deeper evidence is preserved; an interim interpretation that also preserved expired evidence was corrected before publication. The approved one-successful-full-window-response assumption remains: anomalous partial vendor responses can defer discovery of older bars until the 30-day recheck.
Verdict: No remaining blocking findings in this PR range. GitHub confirms the published head is CLEAN/MERGEABLE. PR #114 remains stacked on the still-open #112; merge #112 first. No merge performed.
Review hardening co-authored by Codex codex@openai.com.
Why
Both cache-coverage checks require
first_date <= requested_start, whererequested_startis 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:
What changed
A new
.firstbarsidecar 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:
VENDOR_EARLIEST_RECHECK_DAYS), because vendors do occasionally backfill history. One request per affected symbol per month is negligible; a permanent belief is not.Noneand 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.
Codex review follow-up
Follow-up commit:
3e0104f69eeb7020f2d15c5138b7470413dc8f95. Current PR #112 base was reconciled in84339285ab5b8dd409b4937a5335817b5ac49ba6; this PR remains stacked until #112 merges.PR #112's strict historical default, explicit scanner-only tail opt-in, seven-day tail bound, actual-frame recheck, and exact-row incremental deduplication remain intact. No schema, dependency, or frontend changes.
The approved one-successful-full-window-response assumption remains, with a 30-day recheck. A vendor partial-success anomaly could still defer discovery of older bars until that recheck; this follow-up does not claim to prove vendor completeness.
Measured on a copy of the real 577-file cache
nifty_500, 500 rows, same data for both sides:
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=4took.Gates
Verification at follow-up head
3e0104f:5bc2e5f7-e0aa-481c-aac1-a527427dd3a8: no vulnerabilities found; all changed production source reviewed, with tests/docs as supporting evidence. TAC advisory status could not be verified because its connector was disconnected.Local test environment note: the host inherits
LOG_FORMAT=json, which exposes a pre-existingrecord.messageassertion failure intest_run_scan_persists_good_rows_when_one_row_breaks_the_contracton both Python 3.12 and 3.13. The full passing run usedLOG_FORMAT=textonly in the test subprocess, matching CI's development default. No test was excluded, and no saved environment or unrelated source file was changed. Docker is not installed locally; hosted checks provide that evidence.Housekeeping:
.firstbarjoins.checkedand.repairedincleanup_stale_cache_files, so an orphan is removed with its parquet.🤖 Generated with Claude Code
Review hardening co-authored by Codex codex@openai.com.