feat(DATA-002): repair dirty candle data during the prefetch - #110
Conversation
DATA-001 gave the app eyes but no hands: validate_candles could quarantine a bad symbol and log it, but nothing ever fixed the cached parquet, and the `python app.py` prefetch never validated what it had just written. A corrupt cache file therefore stayed corrupt forever while its symbol was silently dropped from every scan. Measured against a real 577-file cache, no file was clean and 19 symbols were fatally dirty. This adds a repair pass that runs at the end of the prefetch, after the candles are topped up and before Streamlit boots. The design principle is that a repair never invents a price: no interpolation, no clamping, no swapping a high and a low, no split adjustment. Each repair is one of three honest moves - remove redundancy, ask DhanHQ again, or drop what cannot be trusted, and only after the vendor has had its chance to re-supply it. Structure follows the existing DATA-001 split: repair.py is a pure planner (no disk, no network, never mutates its input) so every rule is testable with a three-row DataFrame; cache_repair.py executes, re-validates its own work, and reports honestly what stayed dirty. Notable decisions: - Duplicate dates needed two opposite fixes. Byte-identical rows de-duplicate offline at zero cost in trading days; rows that disagree on price are two merged series (symbol renames) and get a full re-download. The volume-only tiebreak keeps the highest-volume bar, because the loader's existing keep="last" rule picks the partial intraday snapshot over the real EOD bar. - The drop budget is measured in trading days, not rows, so removing 1,238 redundant rows is always allowed while eating real history never is. - The cache is only rewritten when re-validation shows genuine improvement, making "unrepairable means untouched" a real invariant. - Writes go through a temp file + os.replace so an interrupted run cannot corrupt the cache. - DailyDataLoader.fetch_window is new: every other fetch path writes straight to the parquet, which would truncate a ten-year file to the repaired window. Verified against a copy of the real cache with no network: fatal symbols 19 -> 5 (the 5 need a vendor refetch), 1,284 rows removed, zero trading days lost to any de-duplication, one impossible bar dropped, and a second run is a no-op. Two guards came directly out of that verification: a refetch circuit breaker (an expired access token fails identically for every symbol, which would otherwise cost one wasted request per symbol on every launch) and a cache-only short-circuit so a missing credential reports one clear reason instead of a wall of identical errors. Adds candle_repair_runs + migration 20260817data002 and a passive Admin health panel; repairs are also runnable standalone via `python -m backend.jobs.repair_candle_cache`. 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: c76a5da9d8
ℹ️ 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".
| # No change in fatal status: only count it as progress if warnings shrank. | ||
| return fatal_after == 0 and len(after.findings) < len(before.findings) |
There was a problem hiding this comment.
Preserve improvements when the finding count stays flat
When a cache has multiple calendar gaps and Dhan fills only one, validate_candles still returns one CALENDAR_DATE_GAP finding before and after because the number of gaps is stored in affected_rows. Comparing only len(after.findings) therefore rejects the improvement, marks the symbol unrepairable, and discards the successfully recovered candles instead of writing a partial repair. Account for reduced affected rows or otherwise compare finding details, not only the number of finding objects.
Useful? React with 👍 / 👎.
| # DATA-002: now that every candle file is as up to date as Dhan allows, try to | ||
| # CLEAN the ones that are malformed. Doing it here — after the update, before | ||
| # Streamlit boots — means the app always starts against the best cache we can | ||
| # produce, instead of silently dropping corrupt symbols from every scan. | ||
| repair_summary = repair_candle_cache_assets( |
There was a problem hiding this comment.
Run offline repairs when Dhan credentials are unavailable
When DhanDataClient.from_env() raises for a missing or expired token, prefetch_data_assets() returns before reaching this new repair call. That skips repairs which require no client, such as exact de-duplication and dropping impossible bars, even though repair_universe explicitly supports a cache-only loader; affected fatal caches consequently remain quarantined from scans on every python app.py launch until credentials are restored or the standalone job is run.
Useful? React with 👍 / 👎.
| summary = repair_universe( | ||
| loader, | ||
| rows, | ||
| today=resolved_today, | ||
| years_back=years_back, | ||
| force=force, | ||
| dry_run=dry_run, | ||
| ) |
There was a problem hiding this comment.
Create the repair audit row before starting the pass
If the process crashes or repair_universe() raises during this call, execution never reaches persist_repair_summary(), so no candle_repair_runs row is created at all. This contradicts the new model and repository lifecycle that use finished_at IS NULL to expose an interrupted pass, leaving Admin health unable to distinguish a crashed repair from one that never started; create and commit the header before beginning the repair, then finish it afterward.
Useful? React with 👍 / 👎.
| symbols_repaired=summary.symbols_repaired | ||
| + summary.symbols_partially_repaired, | ||
| symbols_unrepairable=summary.symbols_unrepairable, |
There was a problem hiding this comment.
Include partial repairs in the still-dirty count
A partially_repaired outcome still has findings by definition and is included by CacheRepairSummary.needs_attention, but this persistence call records only symbols_unrepairable. Since the health page presents this database column as “Still dirty symbols,” a pass containing only partial repairs reports zero even though those symbols still require attention; include symbols_partially_repaired in the persisted count or expose a separate partial counter.
Useful? React with 👍 / 👎.
Four P2 findings, three accepted as-is and one addressed differently. 1. Preserve improvements when the finding count stays flat. validate_candles reports ALL calendar gaps as a single CALENDAR_DATE_GAP finding and keeps the gap count in affected_rows. Judging improvement on len(findings) therefore rejected a repair that filled one of three holes, marked the symbol unrepairable, and discarded the candles Dhan had just supplied. Replaced with a (fatal, total findings, total affected rows) profile compared lexicographically - fatal still dominates, so trading a fatal finding for a warning (dropping a bar leaves a one-day hole) is still accepted. Regression test added alongside one proving no-op repairs are still rejected. 2. Run offline repairs when Dhan credentials are unavailable. prefetch_data_assets returned early when DhanDataClient.from_env() raised, skipping repairs that need no client at all - exact de-duplication and dropping impossible bars, which are precisely what quarantine whole symbols from every scan. The no-credentials path now runs the repair with a cache-only loader before returning. 3. Create the repair audit row before starting the pass. The model and migration document finished_at IS NULL as the marker of an interrupted pass, but the row was created and finished together afterwards, so a crash left no row at all. Split into open_repair_run (commits the header first) and persist_repair_summary (stamps the counts, falling back to creating a row if the header could not be opened). Both the CLI and the prefetch use it; a dry run still writes nothing. 4. Include partial repairs in the still-dirty count - addressed differently. The persisted count is correct; the health *label* was not. A partially_repaired symbol has usually had its fatal findings cleared and is scannable again - TVSHLTD on the real cache went from [CLOSE_OUTSIDE_RANGE, OPEN_OUTSIDE_RANGE, STALE] to [STALE]. Counting it as "still dirty" would raise a false alarm, and conflating it with unrepairable would hide genuine failures. Renamed the metric to "Unrepairable symbols" (exactly what the column holds) and surfaced the partial count separately from the receipt, so those symbols stay visible without being mislabelled. No migration change needed - the receipt already carried symbols_partially_repaired. Gates on this branch: 1870 tests pass at 89.72% coverage, ruff/bandit/ compileall clean, and mypy clean under the pandas-stubs version this branch pins (2.3.3.260113, which is what CI installs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — all four verified against the code. Three were real bugs and are fixed as suggested; the fourth I addressed differently, reasoning below. Pushed in 1. Preserve improvements when the finding count stays flat — fixed, and this was the worst of the four. Confirmed the premise: Replaced the comparison with a lexicographic profile: (fatal findings, total findings, total affected rows)Fatal still dominates, so trading a fatal finding for a warning — dropping an impossible bar leaves a one-day hole, raising 2. Run offline repairs when Dhan credentials are unavailable — fixed. Correct: the 3. Create the repair audit row before starting the pass — fixed. The code contradicted its own docstrings, which promise 4. Include partial repairs in the still-dirty count — addressed differently. Agreed there's a real mismatch, but I think it's in the label, not the count, so conflating the two would trade one wrong signal for another. Concretely, from the real cache: TVSHLTD went So instead:
No migration change needed. Happy to add a dedicated column instead if you'd prefer partial counts queryable in SQL rather than only in the receipt JSON. Gates on the branch: 1,870 tests pass at 89.72% coverage; ruff, bandit and compileall clean. mypy is clean under the |
Why
DATA-001 gave the app eyes but no hands.
validate_candlesruns at the loader boundary during a scan and everything it can do is defensive: quarantine the symbol, log the codes, fold counts intoscan_runs.data_quality_json. Nothing ever repaired the cached parquet, and thepython app.pyprefetch topped candles up without ever validating what it had just written.The consequence is silent and permanent: a corrupt cache file stays corrupt forever, and its symbol is dropped from every scan without the operator noticing.
Measured against a real 577-file cache on 2026-08-17, no file was clean:
STALE_LATEST_CANDLEDUPLICATE_DATECALENDAR_DATE_GAPSUSPICIOUS_OVERNIGHT_PRICE_GAPOPEN/CLOSE_OUTSIDE_RANGElow == highbutopenbelow it — killing all 2,477 days19 symbols were fatally dirty and being dropped from every scan.
What this does
Adds a repair pass that runs at the end of the prefetch — after the candles are topped up, before Streamlit boots — plus a standalone CLI.
The design principle: never invent a price. No interpolation, no clamping, no swapping a high and a low to make a bar "valid", no split adjustment. Each repair is one of three honest moves: remove redundancy, ask DhanHQ again, or drop what cannot be trusted — and only after the vendor has had its chance to re-supply it. Anything that could plausibly be real vendor data (a 50% overnight move that may be an unadjusted split; a multi-week gap in an illiquid small cap) is left alone and reported as
NO_ACTION_VENDOR_DATA.Structure mirrors the DATA-001 split:
repair.pydecides,cache_repair.pydoes. The planner is pure — no disk, no network, never mutates its input — which is what makes every rule testable with a three-row DataFrame instead of a broker session.The duplicate-date split
The 18 duplicate symbols needed opposite treatments:
The trap this avoids: one file had the same OHLC twice with volumes
164,890then2,245. The loader's existingdrop_duplicates(keep="last")would keep the2,245row — the partial intraday snapshot, i.e. the wrong bar. A naive dedupe makes that file worse. Hence the volume-only tiebreak, which is opt-in, runs only after the vendor has had its chance, and never touches groups whose prices differ.Verified against the real cache
Run against a copy of the live 577-file cache, with no network available:
.tmpfilesTwo guards came directly out of that verification:
Guardrails
os.replace, so an interrupted repair cannot corrupt the cache..checkedmarker. Without this a full universe spends one pointless request per symbol..repairedsidecar (7-day cooldown) — a symbol whose dirt lives in the vendor's own data would otherwise re-download its whole history every morning, forever.try/exceptin the prefetch, same posture as the universe-refresh path.Notable API addition
DailyDataLoader.fetch_window(...)— every other fetch path writes what it downloads straight to the symbol's parquet, which would truncate a ten-year file to the repaired window. This does the network half only (same pacing, DH-904 backoff, timeout) and lets the caller own the result.Persistence
candle_repair_runs+ migration20260817data002, with a bounded, redacted, versioned receipt. A separate table rather than ascan_runscolumn because a repair is not a scan — it runs during the prefetch, before any screener executes. Admin health renders the newest row passively; there is deliberately no "Repair now" button, since a page refresh could then spend hundreds of broker requests.Gates
All CI commands pass locally on the pushed tree: 1,863 passed, coverage 89.91% (floor 87%), plus
ruff,mypy,compileall,bandit,pip-audit, andpre-commit validate-config.git diff origin/main HEAD -- constraints.txt pyproject.tomlis empty (no dependency changes). Migration drift test updated for the new table in both hardcoded table sets.A security review of the diff found no HIGH or MEDIUM issues: no path is built from input (the CLI's
--symbolis a set-membership filter, never a filename), both dynamic error messages go throughredact_text, the receipt takes the samenormalize_secret_safe_jsonhop as audit metadata, and the health panel sits behind the existing admin guard.Operator note
Your Dhan access token is expired, which is why the cache stopped advancing on 2026-07-24. The 5 remaining fatal symbols need a live re-download, so refresh the token (
python Dependencies/dhan_token_setup.py) and the nextpython app.pyshould clear them.🤖 Generated with Claude Code