Detection could retire an actively traded symbol — silently, since it then
vanishes from every signal. Three causes:
- Form 25 is filed per security class. An issuer removing its notes, preferred
or warrants files one while the common keeps trading. The filing's own
descriptionClassSecurity distinguishes them, so the primary document is now
fetched and read; anything not recognisably common equity is rejected, as is
anything unreadable (pre-2009 filings have no primary_doc.xml). Fail closed.
- Form 15 ends a reporting obligation and is no evidence trading stopped. The
whole family is dropped.
- A historical filing for a long-gone class could retire a symbol whose bars ran
years later, stamping the old date. Filings before the last bar (less a 30-day
lead for the exchange) are now ignored.
Rule 12d2-2 makes removal effective ten days after filing, so delisted_on is the
effective date rather than the filing date.
bootstrap_universe(prune_missing=True) still ran a cascading delete over
delisted rows, undoing the retention this branch exists for; it now skips them
and reports kept_delisted so the count is explicable.
clear_delisted had no route, which made "safe to automate because it is
reversible" false — reversal needed SQL. POST/DELETE /tickers/{symbol}/delisting
now mark and un-mark, giving an operator a non-destructive alternative to the
cascading DELETE that was the only option.
Shared-CIK siblings (GOOG/GOOGL) stay safe by construction: the probe is
per-symbol and gated on that symbol's own staleness, so a class that still
trades is never probed.
Not addressed: pruning a symbol merely dropped from the index still destroys its
history — the same survivorship problem in a different costume, needing a
tracked/membership state separate from delisting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
8.6 KiB
Python
235 lines
8.6 KiB
Python
"""Ticker Registry service: add, delete, list, and retire tracked tickers."""
|
|
|
|
import logging
|
|
import re
|
|
from datetime import date, timedelta
|
|
|
|
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
|
|
|
|
# Rule 12d2-2: a Form 25 removal takes effect ten days after filing, so the
|
|
# filing date is not the date the security stopped trading.
|
|
FORM_25_EFFECTIVE_DAYS = 10
|
|
|
|
# How far before the last bar a Form 25 may be filed and still explain this gap.
|
|
# An exchange can file shortly before trading actually stops; anything older
|
|
# concerns a class that was already gone while the symbol kept printing bars.
|
|
FILING_LOOKBACK_DAYS = 30
|
|
|
|
|
|
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, so the staleness path can call it every run without churning the
|
|
row: re-marking is a no-op. The one exception is an SEC confirmation landing
|
|
on a row an operator marked by hand — Form 25 carries the real effective
|
|
date, so it replaces the operator's estimate. Nothing downgrades a confirmed
|
|
row back to a manual one.
|
|
"""
|
|
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:
|
|
upgrading = (
|
|
reason == REASON_FORM_25 and ticker.delisted_reason != REASON_FORM_25
|
|
)
|
|
if not upgrading:
|
|
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 not ticker.cik:
|
|
return None
|
|
# Already confirmed by SEC — nothing left to learn. A row an operator marked
|
|
# by hand is still worth probing: Form 25 upgrades the estimated date.
|
|
if ticker.delisted_reason == REASON_FORM_25:
|
|
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:
|
|
# Only a Form 25 filed around or after the last bar can explain THIS
|
|
# gap. An older one belongs to a class that stopped trading before
|
|
# the symbol was still printing bars, and must not retire it.
|
|
filing = await client.delisting_filing(
|
|
ticker.cik, not_before=last_bar - timedelta(days=FILING_LOOKBACK_DAYS)
|
|
)
|
|
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
|
|
# Removal takes effect ten days after filing, so the filing date is not the
|
|
# date the symbol stopped trading.
|
|
effective = filing["filing_date"] + timedelta(days=FORM_25_EFFECTIVE_DAYS)
|
|
if await mark_delisted(
|
|
db, normalised, delisted_on=effective, reason=REASON_FORM_25
|
|
):
|
|
return effective
|
|
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
|