feat(OBS-004): alert when a universe silently loses mapped symbols - #124
Open
DoRmAmMu1997 wants to merge 1 commit into
Open
feat(OBS-004): alert when a universe silently loses mapped symbols#124DoRmAmMu1997 wants to merge 1 commit into
DoRmAmMu1997 wants to merge 1 commit into
Conversation
When Dhan's instrument master stops listing a symbol, refresh_universe_files() marks it `missing_security_id` and mapped_only() filters it out of every scan. That is correct - we cannot fetch candles for a security id we do not have - but nothing said so outside the interactive Streamlit sidebar. universe_status() had exactly one non-test caller (ui/status_panel.py), the headless daily job emitted no mapping signal, and backend/notifications/ never mentioned mapping_status. So in production, where the Render cron refreshes universes and scans with no human watching a sidebar, a universe could shrink indefinitely. It already had: ~3% of the Hemant Good 200 list was unscanned when this was found, and two more names dropped out mid-audit without a sound. Both were genuine vendor events - absent from the 2026-08-24 master by symbol AND company name, with the snapshot itself intact - so the system should report them, not prevent them. Three pieces, split by requirement: collect_universe_health() and detect_mapping_regressions() are pure and trivially testable; check_universe_health() is the stateful one because "worse than last time" needs a durable baseline. The baseline is a database table, not a file, because the Render daily-scan cron runs on an ephemeral filesystem with no disk - a file-based baseline could never fire the alert in the one environment that needs it. Rows are append-only: the question after "GUJGASLTD dropped out" is always "when?". Two rules keep the alert credible enough that nobody mutes the channel. A universe with no previous row never regresses, so the first run does not alert on every pre-existing unmapped symbol. And only an increase counts, so steady-state damage stays quiet and recovery is not an alert either. Only the alerting path owns the baseline. Whoever writes it defines "last time", so if the morning prefetch also recorded, a symbol dropping out at 09:00 would already be baseline by the evening and the alert would never fire. The prefetch therefore logs health without persisting; the daily job reads the baseline, compares, and only then records today's snapshot - which is what makes the alert fire exactly once. Bounded by construction: MAX_REPORTED_SYMBOLS caps both the stored JSON and the alert text, and only symbols and counts are stored, never prices. Every entry point catches broadly and degrades to no-alert: a universe CSV that will not parse is a reason to warn, never a reason to skip the night's scan. Ships the Alembic migration in the same commit and updates both hardcoded table-name sets in tests/test_scan_storage_migrations.py, per AGENTS.md section 7. Design rationale, including the alternatives rejected, in docs/architecture/obs-004-universe-health-alerts.md. Closes #119 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #119.
Problem
When Dhan's instrument master stops listing a symbol,
refresh_universe_files()marks itmissing_security_idandmapped_only()filters it out of every scan. That is correct — we cannot fetch candles for a security id we do not have. What was missing is that nothing said so:universe_status()had exactly one non-test caller:ui/status_panel.py, the interactive sidebar.backend/jobs/run_daily_scan.pyemitted no mapping signal at all.backend/notifications/never referencedmapping_status.So in production — Render's cron refreshes universes and scans with nobody watching a sidebar — a universe could shrink indefinitely. It already had: ~3% of the Hemant Good 200 list was unscanned, and two more names dropped out mid-audit without a sound.
Worth being clear, because it shaped the design: the drop-outs were not a bug.
JBCHEPHARMandGUJGASLTDwere genuinely absent from the 2026-08-24 master by symbol and company name, with the snapshot itself intact at 213,213 rows. This is a real vendor event the system should report, not prevent.Design
Three pieces, split by requirement.
collect_universe_health()anddetect_mapping_regressions()are pure and trivially testable;check_universe_health(session)is the stateful one, because "worse than last time" needs a durable baseline.A table, not a file. The Render daily-scan cron runs on an ephemeral filesystem with no disk —
render.yamlattaches the disk to the web service only. A file-based baseline could never fire the alert in the one environment that needs it. Rows are append-only rather than upserted, because the question after "GUJGASLTD dropped out" is always "when?".Two rules keep the alert credible enough that nobody mutes the channel:
Only the alerting path owns the baseline. This is the subtle one. Whoever writes the baseline defines what "last time" means, so if the morning prefetch also recorded a snapshot, a symbol dropping out at 09:00 would already be baseline by the evening and the alert would never fire. The prefetch therefore logs health without persisting; the daily job reads the baseline, compares, and only then records today's — which is exactly what makes the alert fire once.
Bounded by construction.
MAX_REPORTED_SYMBOLS = 25caps both the stored JSON and the alert text, so a badly broken CSV cannot write an unbounded blob to Postgres or a multi-page Telegram message. Symbols and counts only — never prices — and the text still goes throughredact_text.Failure posture. Every entry point catches broadly and degrades to no-alert. A universe CSV that will not parse is a reason to warn, never a reason to skip the night's scan; the check can't change the job's exit code.
What an operator sees
universe_health_checkedper universe on every run (rows / mapped / unmapped), auniverse_mapping_regressedwarning when something got worse, a printed job line, and a new alert block:That block renders even at the ALERT-002 summary-only content level — a shrinking universe is a warning about the integrity of this scan, not a per-stock result.
Acceptance criteria from the issue
test_check_alerts_exactly_once_when_a_symbol_drops_out(runs the check three times)test_steady_state_and_recovery_do_not_regress,test_no_baseline_never_regressestest_log_universe_health_emits_one_event_per_universeVerification
pytest -q --cov=... --cov-fail-under=89— 2052 passed, 1 skipped, coverage 90.07% (up from 89.97%)backend/data_quality/universe_health.py— 93% from its own teststests/test_scan_storage_migrations.py— passes, soalembic upgrade headbuilds exactlyBase.metadataruff check,compileall,bandit— clean;mypyclean (261 files, run as--python-version 3.13; the pinned 3.11 target cannot run on this machine — numpy 2.5.2 vs the pinned 2.4.6 — so CI is the authority there)git diff origin/main HEAD -- constraints.txt pyproject.toml— emptyPer AGENTS.md §7 the Alembic migration ships in the same commit as the ORM change, and both hardcoded table-name sets in
tests/test_scan_storage_migrations.pyare updated.Alternatives rejected
Recorded in full in
docs/architecture/obs-004-universe-health-alerts.md§5 — briefly: an absolute threshold (every universe needs its own tuned number, and slow drift stays invisible),app_configas the store (it is read wholesale byapply_config_overrides(); job state could be applied as a setting), upserting one row per universe (throws away the "when?" answer for no real saving), and failing the scan on a shrink (wrong severity — a delisting is normal and the remaining scan is still valid).🤖 Generated with Claude Code