feat(universe): separate scanner membership from retained history
Deploy / lint (push) Successful in 43s
Deploy / test (push) Failing after 2m9s
Deploy / deploy (push) Skipped

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
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-09-07 19:30:05 +02:00
1 parent f2ef8e5438
commit ad3e47dc50
17 files changed
+2027 -57

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
# Data reliability
Three changes that make the platform's data trustworthy rather than faster or
more profitable. They came out of the [integrity
experiments](data-integrity-plan.md), 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](research/survivorship-audit.md)).
- **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"]`:
```json
{
"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.