Files
signal-platform/app/services/ticker_service.py
T
dennisthiessenandClaude Opus 5 6501b7e9a0 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>
2026-08-11 16:58:51 +02:00

208 lines
7.2 KiB
Python

"""Ticker Registry service: add, delete, list, and retire tracked tickers."""
import logging
import re
from datetime import date
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.ticker import Ticker
logger = logging.getLogger(__name__)
# Reasons a symbol may be marked delisted, narrowest first.
REASON_FORM_25 = "form_25" # SEC Form 25/25-NSE/15 confirmed the exchange exit
REASON_MANUAL = "manual" # an operator decided
# How long a symbol must be without bars before we spend an SEC request asking
# whether it delisted. Guards against a market-data outage probing the whole
# universe at once; a real delisting is still stale days later.
MIN_STALE_DAYS_BEFORE_PROBE = 3
def _sec_client_factory():
"""Build the SEC client for a delisting probe (patched in tests).
Imported lazily so the SEC/httpx stack stays off the import path of every
module that only wants ``active_only``.
"""
from app.services.sec_client import SecClient
return SecClient()
def active_only(stmt):
"""Restrict a Ticker query to symbols that still trade.
Opt-in on purpose rather than folded into a shared getter: list and admin
views deliberately keep delisted rows so the delisting is *visible*, which a
silent default would undo. Apply this on the live signal path — scanning,
ranking, scoring, breadth, ingestion — and nowhere else.
"""
return stmt.where(Ticker.delisted_on.is_(None))
async def add_ticker(db: AsyncSession, symbol: str) -> Ticker:
"""Add a new ticker after validation.
Validates: non-empty, uppercase alphanumeric. Auto-uppercases input.
Raises DuplicateError if symbol already tracked.
"""
stripped = symbol.strip()
if not stripped:
raise ValidationError("Ticker symbol must not be empty or whitespace-only")
normalised = stripped.upper()
if not re.fullmatch(r"[A-Z0-9]+", normalised):
raise ValidationError(
f"Ticker symbol must be alphanumeric: {normalised}"
)
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
if result.scalar_one_or_none() is not None:
raise DuplicateError(f"Ticker already exists: {normalised}")
ticker = Ticker(symbol=normalised)
db.add(ticker)
await db.commit()
await db.refresh(ticker)
return ticker
async def delete_ticker(db: AsyncSession, symbol: str) -> None:
"""Delete a ticker and cascade all associated data.
Raises NotFoundError if the symbol is not tracked.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
await db.delete(ticker)
await db.commit()
async def list_tickers(db: AsyncSession) -> list[Ticker]:
"""Return all tracked tickers sorted alphabetically by symbol.
Delisted symbols are included and carry ``delisted_on`` — the registry is
where an operator needs to *see* that a symbol retired, not where it should
quietly disappear.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol.asc()))
return list(result.scalars().all())
async def mark_delisted(
db: AsyncSession,
symbol: str,
*,
delisted_on: date,
reason: str = REASON_MANUAL,
) -> bool:
"""Record that a symbol stopped trading. True if this changed anything.
Idempotent: re-marking an already-delisted symbol is a no-op, so the
staleness path can call it on every run without churning the row or
re-emitting events.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
if ticker.delisted_on is not None:
return False
await db.execute(
update(Ticker)
.where(Ticker.id == ticker.id)
.values(delisted_on=delisted_on, delisted_reason=reason)
)
await db.commit()
logger.info(
"ticker %s marked delisted on %s (%s)", normalised, delisted_on, reason
)
return True
async def confirm_delisting(
db: AsyncSession,
symbol: str,
*,
last_bar: date | None,
today: date | None = None,
) -> date | None:
"""Ask SEC whether ``symbol`` actually delisted; mark it if so.
Called when OHLCV goes stale, because "no new bars" alone cannot tell a
delisting from a halt or a rename. Returns the effective date when this call
marked the symbol, else ``None`` — already-marked and unconfirmed both return
``None``, so the caller keeps its existing alert for anything unproven.
Deliberately driven by staleness rather than by the SEC fundamentals import:
that importer stalls for days at a time on unrelated Company-Facts gaps, and
detection wired into it would stall with it.
The probe waits for ``MIN_STALE_DAYS_BEFORE_PROBE``. A delisted symbol stays
stale forever, so the delay costs nothing, and it keeps a broad market-data
outage — where every tracked symbol reports stale at once — from turning into
one SEC request per symbol per run.
"""
from app.services.sec_client import SecError
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None or ticker.delisted_on is not None or not ticker.cik:
return None
# No bars at all is an ingestion problem, not evidence of a delisting.
if last_bar is None:
return None
if ((today or date.today()) - last_bar).days < MIN_STALE_DAYS_BEFORE_PROBE:
return None
try:
async with _sec_client_factory() as client:
filing = await client.delisting_filing(ticker.cik)
except SecError:
# Never let a probe failure escalate a routine staleness warning.
logger.warning("delisting probe failed for %s", normalised, exc_info=True)
return None
if filing is None:
return None
if await mark_delisted(
db, normalised, delisted_on=filing["filing_date"], reason=REASON_FORM_25
):
return filing["filing_date"]
return None
async def clear_delisted(db: AsyncSession, symbol: str) -> bool:
"""Un-retire a symbol. True if it had been marked.
The counterpart that makes automatic marking acceptable: a false positive
costs one row update, where a delete would have cost the price history.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
if ticker.delisted_on is None:
return False
await db.execute(
update(Ticker)
.where(Ticker.id == ticker.id)
.values(delisted_on=None, delisted_reason=None)
)
await db.commit()
logger.info("ticker %s un-marked as delisted", normalised)
return True