"""Record delisting on tickers instead of deleting them Revision ID: 032 Revises: 031 Create Date: 2026-08-11 00:00:00.000000 Until now the only way to retire a symbol was ``delete_ticker`` (or ``bootstrap_universe(prune_missing=True)``), both of which cascade through OHLCV, setups and scores. That destroys exactly the history four research documents already apologise for: today's tracked universe projected backward is survivorship-biased, and hard-deleting every delisted name is what causes it. Keeping the rows preserves the option to fix that later — it does not fix it by itself, which needs the replay to model a delisting as an exit event. ``delisted_on`` is the effective date (from SEC Form 25/25-NSE/15 where we can confirm it, else the day it was marked); ``delisted_reason`` is a short code for how we learned. NULL in both means actively traded — the live signal path filters on that, while list and admin views keep showing the row so the delisting is visible rather than silently absent. Nullable and reversible by design: clearing ``delisted_on`` un-retires a symbol, which is what makes automatic marking safe where a delete would not be. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "032" down_revision: Union[str, None] = "031" 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("delisted_on", sa.Date(), nullable=True)) op.add_column( "tickers", sa.Column("delisted_reason", sa.String(length=32), nullable=True) ) # The live path filters "actively traded" on every universe scan; the index # keeps that predicate cheap as delisted rows accumulate. op.create_index("ix_tickers_delisted_on", "tickers", ["delisted_on"]) def downgrade() -> None: op.drop_index("ix_tickers_delisted_on", table_name="tickers") op.drop_column("tickers", "delisted_reason") op.drop_column("tickers", "delisted_on")