Files
dennisthiessenandClaude Opus 5 ad3e47dc50
Deploy / lint (push) Successful in 43s
Deploy / test (push) Failing after 2m9s
Deploy / deploy (push) Skipped
feat(universe): separate scanner membership from retained history
bootstrap_universe(prune_missing=True) ran DELETE FROM tickers, cascading OHLCV,
setups, scores and S/R levels away for any symbol that had merely dropped out of
the index. Migration 032's own comment predicted this. The survivorship audit
measured what it costs: 2.09 Sharpe against 1.57 on universe composition alone.

Migration 035 adds untracked_on, the sibling of delisted_on — delisted means the
security stopped trading, untracked means a prune took it out of the scanner.
Nothing is deleted either way.

Migration 036 separates three more things that were still conflated:

  universe_memberships       what the index actually contained, when
  tickers.untracked_on       excluded from scanning by a prune
  tickers.scan_disabled_on   the operator's own "stop scanning this"
  tickers.tracking_source    who owns the row: a bootstrap, or an operator

The operator flag is its own column because with one column serving both
writers, a prune -> re-entry cycle would clear an explicit "off" — re-entry
cannot tell who set it. Re-entry clears untracked_on and never touches
scan_disabled_on.

untracked_on is NOT current index membership. The scheduled sync runs
prune_missing=False, so a symbol can have left the index and still be scanned;
the two legitimately differ and nothing asserts they agree.

Tracking ownership is sticky towards manual: prune touches index-owned rows
only, and a bootstrap never converts manual to index — a hand-added symbol that
joins the index would otherwise lose its protection the moment it left again,
which is exactly when it is needed. An explicit add of an index-owned symbol
claims it as manual without touching either off-switch.

Membership is observed, never asserted. first_seen_on is the first live
sighting; absent_since is the first observation that found the symbol GONE, so
the interval is [first_seen_on, absent_since) — named for what it stores rather
than "last_seen_on", which would have claimed the opposite. Intervals are
append-only, so a symbol that leaves and returns has two and a point-in-time
query for the gap correctly answers "not a member". members_on() implements the
boundary rule once. A date before the first live sync returns empty: that means
"we were not looking", not "the index was empty".

A failed fetch drives nothing. The cache and seed fallbacks mean the live fetch
failed, and the seed lists are ~44 symbols against a ~500-symbol universe — a
regression test caught a seed fallback untracking 2 of 3 tickers. Recording,
pruning and clearing prune flags all require a live source; only adding still
runs, so a cold start can still bootstrap from seeds.

Migration decisions: existing rows become manual (fail-safe — a wrong guess
costs an un-pruned symbol, not one swept out of the scanner), and existing
untracked_on values are copied into scan_disabled_on, since the original writer
is ambiguous and re-entry clears the prune column.

Backtest eligibility is unchanged. _load_backtest_symbols deliberately does not
use active_only, so today's scanner state cannot erase yesterday's candidates,
and untracking a symbol still cannot silence a coverage gap in the report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAH7vVtn7qbcn1EVheecHQ
2026-09-07 19:30:05 +02:00

19 KiB

Data reliability

Three changes that make the platform's data trustworthy rather than faster or more profitable. They came out of the integrity experiments, which turned up two problems worth fixing on their own:

  • Changing the stock universe materially changes results — Arm A 2.09 Sharpe / 51.6% CAGR against Arm B 1.57 / 38.1% over the same window, from universe composition alone (survivorship audit).
  • Missing data quietly produces a seemingly successful backtest. Found while running Step 3a: a snapshot whose schema predated migrations 026/032 made every _fetch_columns call raise no such column: tickers.cik, both dispatch paths caught the per-ticker exception and continued, and the run wrote a complete report with zero candidates. Nothing in the report or the UI said the run had lost its entire universe.
# Change Status
1 Make incomplete backtests visibly incomplete done — 2026-09-07
2 Preserve history when a stock leaves the tracked universe done — 2026-09-07
3 Record universe membership by effective date done — 2026-09-07

1. Make incomplete backtests visibly incomplete — done

The replay loop is deliberately fault-tolerant: one unreadable ticker must not abort a 500-symbol run. That is right for the run and wrong for its report, which had no way to say how much of the universe it actually covered. A run that lost every ticker and a run that honestly found no setups produced the same report.

What the report now carries

run_backtest builds a _DataQuality ledger (app/services/backtest_service.py) and emits it as report["data_quality"]:

{
  "status": "failed",
  "complete": false,
  "reasons": ["no symbol was evaluated successfully", "506 symbols failed to load"],
  "expected": 506, "loaded": 0, "evaluated": 0,
  "no_data": 0, "no_data_symbols": [], "short_history": 0,
  "failed": 506,
  "failures_by_stage": {"fetch": 506},
  "failures": [{"symbol": "A", "stage": "fetch",
                "error": "OperationalError: (sqlite3.OperationalError) no such column: tickers.cik"}],
  "failures_truncated": true,
  "benchmark": {"symbol": "SPY", "sessions": 1516,
                "first": "2020-07-06", "last": "2026-07-17",
                "momentum_basis": "residual", "error": null}
}

"Complete" means the processing and coverage checks passed — every expected symbol loaded and evaluated, benchmark present. It does not mean the underlying prices are proven correct. A complete run over silently wrong bars is still complete; splits, adjustment drift and bad vendor prints are a different problem, and this ledger must not be read as clearing them. The card says so on the page, not only here.

The counters are self-checking. Every universe symbol ends in exactly one of no_data, failures or evaluated, and as_report verifies no_data + failed + evaluated == expected. A ledger that stops balancing — because a counter was missed when a code path was added — reports itself as a failure rather than quietly under-counting. short_history is a subset of evaluated (loaded, but fewer than MIN_LOOKBACK + HORIZON bars, so no outcome can resolve), not a fourth bucket.

Benchmark availability is a separate axis. Without SPY closes, residual 12-1 momentum silently falls back to raw 12-1 — the gate stops being the production gate. The block records the basis actually used, so the degradation is on the report instead of only in a log line.

Three statuses, because severity differs

Status Meaning UI
ok Every symbol evaluated, benchmark present Normal
degraded The strategy is intact but ran on less than the whole universe Tiles shown, banner above
failed The headline can't be read as the strategy it claims to be Tiles replaced by the callout

failed means one of: nothing evaluated, an empty universe, an unbalanced ledger, or a missing benchmark. Blanket suppression on a single missing ticker would train the reader to ignore the banner, which is the failure mode this is supposed to fix.

The callout replaces the performance tiles rather than sitting above them. A warning over ordinary-looking numbers is precisely how the zero-candidate run would still have read as a quiet week.

Where it shows

  • BacktestDataQualityCard in the backtest panel, with the failure list behind a disclosure.
  • The "Last run" line now reads 505/506 tickers — evaluated over expected, not the universe size. "506 tickers" on a run that evaluated none was the misleading part.
  • productionBaseRates returns null for a failed run, so per-ticker base rates quoted next to a live setup — which carry no caveat of their own — disappear rather than being sourced from a broken replay.
  • EvaluationPanel drops a failed run's expectancy as a drift baseline instead of reporting "tracking" or "drift" against it.
  • A report with no data_quality block reads as unknown, not clean. Old cached reports predate the check; defaulting them to complete would reproduce the exact bug being fixed.

Acceptance evidence

Both runs used scripts/run_backtest_snapshot.py.

Snapshot Result
survivorship-a.sqlite (healthy) degraded · 505/506 evaluated · 202,765 candidates · one bar-less row (RHM)
the same file with tickers.cik dropped failed · 0/506 evaluated · 506 fetch failures · 0 candidates

The broken-snapshot run is the one that matters: before this change it wrote a report that looked like an ordinary run with no setups.

The healthy run is not ok, and that turned out to be correct. It flagged no_data: 1 (RHM) and short_history: 1 (SPCX, 14 bars), which an independent bar-count query confirms. Neither is new — Step 2 already recorded RHM as inert in tr-rank-agreement.md, which is why Step 3a generated candidates over 505 of 506 tickers. The ledger derives it independently, from the run rather than from a hand-written note, which is the point: the same 506-vs-505 gap now appears on the report instead of only in a research write-up someone remembered to make.

prod.sqlite has the same bar-less RHM row, so production runs will report degraded too until that universe row is cleared — which is item 2's territory. That is deliberate: silencing a bar-less universe row would let a wholesale import failure read as clean. The reason names the symbols so it is a one-line fix rather than a standing nag.

Tests: tests/unit/test_backtest_data_quality.py (10, covering each status, the ledger identity, the failure cap, symbol naming and the benchmark axis) and four end-to-end cases in tests/unit/test_backtest_service.py, including every-ticker-fails and missing-benchmark.


2. Preserve history when a stock leaves the tracked universe — done

bootstrap_universe(prune_missing=True) ran DELETE FROM tickers, cascading OHLCV, setups, scores and S/R levels away for any symbol that had merely dropped out of the index. Migration 032's own comment predicted it: "Pruning a symbol that is merely no longer an index constituent still destroys history; that needs a tracked/membership state separate from delisting."

The two states, kept apart

Migration 035 adds Ticker.untracked_on, the sibling of delisted_on:

Column Meaning
delisted_on The security stopped trading
untracked_on It still trades; it left the index and we stopped scanning it

Neither deletes anything. NULL in both means "in today's live path". active_only — the single opt-in choke point already applied to scanning, ranking, scoring, breadth and ingestion — now filters on both, and honours as_of for both, so a historical query can still ask what was in the scanner on a given date.

prune_missing=True now marks instead of deleting, and clears the mark for symbols that have rejoined the universe. That last part is the bug this refactor could have introduced: a returning name is already tracked, so it is neither added nor untracked, and without an explicit clear it would keep the flag from its earlier removal and stay invisible to the scanner forever.

The deleted return key is now always 0 rather than repurposed — a caller reading deleted: 12 must not be told rows were destroyed when they were not. untracked, untracked_symbols and retracked carry the real counts, and the admin toast reads "untracked N" instead of "deleted N".

Historical eligibility stays explicit

The backtest's universe query is now _load_backtest_symbols, a named function whose docstring states that it deliberately does not use active_only. Today's membership flag must not erase yesterday's candidates: a replay grades what the strategy could have selected at each past date, so it must see symbols that have since been delisted or dropped out. "Fixing the inconsistency" by adding active_only there is exactly how survivorship bias gets reintroduced, which is why a test asserts the opposite.

The one path that still really deletes is DELETE /tickers/{symbol} — an explicit operator action asking for exactly that. Pruning is not.

Untracking one symbol

POST /tickers/{symbol}/tracking with {"tracked": false} excludes a single symbol from the live path, mirroring the delisting pair (both are API-only; no UI control exists for either). It exists because prune_missing=True can only act on the whole universe, and a manually added symbol is by definition not in the fetched constituent list — so a prune sweeps every manual addition along with the genuine leavers. Reversible with {"tracked": true}.

The case it was built for: RHM. Added by hand on 2026-06-28 out of interest in the stock, it has produced zero rows in every ticker-linked table in the ten weeks since — no bars, no ingestion progress, no scores — and the Alpaca name backfill never resolved it. It is the only symbol in the universe that has produced nothing at all (BK, CTRA and SATS also lack Alpaca names but carry full history, so a missing name alone proves nothing). Rheinmetall is XETRA-listed, so a plain US RHM is not something the OHLCV provider can serve. An attempt to confirm this against Alpaca's asset API returned 401 for the AAPL control too, so the identity is unverified — the justification is the persistent absence of usable data, not a confirmed listing status or a delisting.

Untracking it does not clear the backtest's degraded flag, and must not. The backtest deliberately includes untracked symbols, so RHM stays in the universe query, still returns no bars, and still counts in no_data. What untracking buys is narrower and still worth having: RHM leaves the scanner, ingestion and scoring, and the decision is recorded explicitly rather than sitting as an unexplained gap. Making the warning go away would mean changing historical eligibility to silence a real coverage gap — guarded by test_untracking_does_not_clear_a_data_quality_warning.

Note what is not solved: a manual addition is still indistinguishable from a universe member, so a prune untracks it (non-destructively, and visibly via untracked_symbols, but silently in the sense that nobody asked). Recording why a symbol is tracked is item 3's job.

Tests

tests/unit/test_universe_membership.py (12) covers the three behaviours together:

  1. Pruning preserves the symbol's bars and its linked records (S/R levels are checked too, so the assertion is about the cascade, not just one table).
  2. The pruned symbol leaves active_only while staying visible in the registry.
  3. _load_backtest_symbols still returns it.

Plus re-tracking, the mark not being re-stamped on a second prune (which would erase when the symbol actually left), and as_of working for both columns. a row carrying both marks (it left the index, then delisted), and a compilation guard asserting every active_only call site still receives both predicates ANDed — including scheduler.py's, which chains group_by/order_by onto the result from outside. tests/unit/test_migration_035.py exercises the migration's upgrade and downgrade directly, including that existing rows default to NULL — an upgrade must not drop the whole universe out of the live path.

One detail worth keeping: untracked_on is stamped by the database (func.current_date()), not date.today(). active_only compares it against func.current_date(), so a process in a timezone ahead of the database would otherwise write a date that reads as "effective tomorrow" and leave the symbol in the scanner for a day. Same clock on both sides by construction.

3. Record universe membership by effective date — done

Migration 036. Three things that were still conflated are now separate:

Concept Where Written by
Index membership, observed history universe_memberships intervals every live sync
Excluded from scanning by a prune Ticker.untracked_on prune only
Operator switched it off Ticker.scan_disabled_on set_tracked only
Tracking ownership Ticker.tracking_source add_ticker / bootstrap

Why the operator flag is its own column. With one column serving both writers, a prune -> re-entry cycle would clear an explicit operator "off", because re-entry cannot tell who set it. Re-entry clears untracked_on and never touches scan_disabled_on.

active_only now tests three columns and stays join-free — it is on ~8 live call sites, and the compilation guard asserts all three predicates land, ANDed, including at the call site that chains group_by/order_by on from outside.

untracked_on is not current membership

It records exclusion by pruning. The scheduled sync runs prune_missing=False, so a symbol can have left the index and still be scanned; the two states legitimately differ, and nothing asserts they agree.

Tracking ownership is sticky towards manual

tracking_source is index or manual. Prune touches only index rows, so manual additions survive index pruning — the RHM case.

A bootstrap never converts manual to index. That direction looks harmless and is not: a hand-added symbol that happens to join the index would lose its protection the moment it left again, which is exactly when it is needed. Only new rows a bootstrap creates are stamped index; an explicit add_ticker on an existing index-owned symbol claims it as manual.

POST /tickers therefore has three responses:

Case Response
New symbol Created as manually tracked
Existing, index-owned 200 with the existing row, now manually tracked
Existing, already manual 409 Duplicate

Claiming is not a 409 because the operator is saying "keep this stock", and refusing while leaving it prunable would defeat the protection they just asked for. It deliberately does not touch scan_disabled_on or untracked_on — wanting a stock kept is not the same as asking for it to be scanned, and silently re-enabling something switched off by hand would undo a decision nobody revisited. POST /tickers/{symbol}/tracking is the explicit control.

Membership is observed, never asserted

first_seen_on is the first date a live fetch actually listed the symbol — not an index effective date. Anything earlier is unknown and is not guessed; the hand-reconstructed scripts/data/sp500_removals.json stays a research artifact.

Intervals are append-only: departure sets absent_since and retains the row, and a re-entry inserts a new one. A symbol that left in March and returned in August has two intervals, so a point-in-time query for May correctly answers "not a member" — reopening the original row would erase that.

Boundaries, stated once. The column is named absent_since, not last_seen_on, because it stores the first observation that found the symbol gone — on that date it was already absent:

member on D  <=>  first_seen_on <= D < absent_since  (or absent_since IS NULL)

Lower bound inclusive, upper bound exclusive. The real departure lies somewhere in (previous observation, absent_since] — a day with a daily sync, a week if a week was missed. The column records when we looked, which is the only thing actually known. members_on(db, universe, as_of) implements exactly this, so the rule lives in one place rather than being re-derived per call site.

A date before the first successful live sync returns an empty list. That means "we were not looking", not "the index was empty", and a caller has to know the difference — answering with today's set would be precisely the survivorship error this exists to end.

Recording runs on every live sync, independent of pruning and of who owns the row: the scheduled job uses prune_missing=False, so membership recorded only on manual prunes would be full of holes. Manually owned symbols get intervals like any other — membership and tracking ownership are independent axes. Switching off scanning does not close a membership; the index still contains the symbol, we simply stopped looking at it.

A failed fetch drives nothing at all. fetch_universe_symbols falls back to a cached snapshot and then to a built-in seed list of ~44 symbols. Nothing that acts on "this symbol is or isn't in the index" may run on that data: not recording membership, not pruning, and not clearing a prune flag. Skipping only the membership write was half a safeguard — a seed fallback would still have untracked the rest of the universe, which a regression test caught (2 of 3 symbols untracked before the fix). Adding is the one exception: it destroys nothing and is what lets a cold start bootstrap from seeds. The summary reports membership_observed: false so a caller can tell "the index did not change" from "we never looked".

Migration decisions

  • Existing rows become manual. Provenance cannot be recovered from created_at, and manual is fail-safe: a wrong guess costs an un-pruned symbol rather than one swept out of the scanner. Bootstraps claim the ones they own as they create them.
  • Existing "off" states are carried into scan_disabled_on. Whoever wrote an existing untracked_on is ambiguous, and re-entry clears that column — so the value is copied to the one no automated run touches. A symbol somebody deliberately switched off cannot be reactivated by a sync.
  • No history is backfilled. The table starts empty; history begins at the first observation.

Tests

test_universe_membership.py (19) and test_universe_membership_intervals.py (14): manual additions survive a prune; a bootstrap never claims a manual row, including across a join-then-leave cycle; an explicit add claims an index-owned symbol without touching its switches; re-entry clears the prune flag but never the operator's; a live sync opens intervals without pruning; departure closes and re-entry appends; a cache/seed fallback records nothing, untracks nothing and clears no prune flag; membership is recorded for manual rows; switching off scanning does not close a membership; and the boundary rules — lower inclusive, upper exclusive, gaps are not membership, per-universe scoping, and pre-recording dates returning empty. test_migration_036.py (4) pins both migration judgement calls and the downgrade.

The fixtures pass a live source label deliberately: a made-up one would now skip pruning entirely and make those tests vacuous.

Backtest eligibility is unchanged — _load_backtest_symbols still returns every symbol with stored history, and untracking still cannot silence a coverage gap.