"""Separate scanner membership from retained history Revision ID: 035 Revises: 034 Create Date: 2026-09-07 00:00:00.000000 Migration 032 stopped ``delete_ticker`` and the delisting path from destroying history, but left the other half of the problem open — its own comment said so: "Pruning a symbol that is merely no longer an index constituent still destroys history; that needs a tracked/membership state separate from delisting." ``bootstrap_universe(prune_missing=True)`` still ran ``DELETE FROM tickers``, cascading OHLCV, setups and scores away for any symbol that had merely dropped out of the index. The survivorship audit measured what that costs: restoring 85 removed constituents moved the same window from 2.09 Sharpe / 51.6% CAGR to 1.57 / 38.1%. A prune that deletes is how a universe silently becomes the survivors. ``untracked_on`` is the sibling of ``delisted_on``, and the distinction is the point: delisted_on the security stopped trading untracked_on it still trades; we stopped scanning it Both retain every row. NULL in both means "in the live path" — ``active_only`` filters on both, so scanning, ranking, scoring, breadth and ingestion skip an untracked name, while the backtest's universe query deliberately does not, so today's membership cannot erase yesterday's candidates. Nullable and reversible: a symbol that returns to the index has ``untracked_on`` cleared by the next bootstrap and rejoins the live path with its history intact. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "035" down_revision: Union[str, None] = "034" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column("tickers", sa.Column("untracked_on", sa.Date(), nullable=True)) # Same reasoning as ix_tickers_delisted_on: the live path tests this # predicate on every universe scan, and untracked rows accumulate. op.create_index("ix_tickers_untracked_on", "tickers", ["untracked_on"]) def downgrade() -> None: # Dropping the column re-admits every untracked symbol to the live path. # The rows and their history survive either way; that is the whole point. op.drop_index("ix_tickers_untracked_on", table_name="tickers") op.drop_column("tickers", "untracked_on")