feat(tickers): record delisting instead of deleting the symbol

Retiring a symbol meant delete_ticker or bootstrap_universe(prune_missing),
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 — it does not fix it, which needs the replay to model a delisting as an
exit event.

tickers gains delisted_on / delisted_reason (migration 032). NULL means
actively traded.

The filter is opt-in via ticker_service.active_only rather than folded into a
shared getter: the registry and admin views deliberately keep delisted rows so
the delisting is visible, and a silent default would undo that. Applied to the
live path only — scanner, momentum ranking, scoring, breadth, fundamentals
candidates, SEC universe, earnings import, ingestion loops. run_backtest keeps
them on purpose.

Detection runs off OHLCV staleness, not off the SEC fundamentals import: that
importer stalls for days on unrelated Company-Facts gaps and would take
detection down with it. On a stale symbol the scheduler asks SEC for a Form
25/25-NSE/15 and retires it only on a hit, so a halt or a rename (SATS->ECHO)
keeps the existing warning. The probe waits 3 stale days so a market-data
outage cannot turn into one SEC request per symbol per run.

Safe to automate because it is reversible: clear_delisted un-retires a false
positive, where a delete had already taken the history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:58:51 +02:00
co-authored by Claude Opus 5
parent 486fb500d1
commit 6501b7e9a0
15 changed files with 512 additions and 29 deletions
+6 -1
View File
@@ -1,6 +1,6 @@
"""Ticker request/response schemas."""
from datetime import datetime
from datetime import date, datetime
from pydantic import BaseModel, Field
@@ -14,5 +14,10 @@ class TickerResponse(BaseModel):
symbol: str
name: str | None = None
created_at: datetime
# NULL == actively traded. Delisted symbols stay in the registry with their
# history and are excluded from signals — the date is what makes that
# visible instead of the row silently disappearing.
delisted_on: date | None = None
delisted_reason: str | None = None
model_config = {"from_attributes": True}