fix(tickers): close the delisting review findings

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>
This commit is contained in:
2026-08-11 16:58:51 +02:00
co-authored by Claude Opus 5
parent d950fcf70e
commit 1d4ed39fd2
6 changed files with 318 additions and 32 deletions
+21 -4
View File
@@ -2,7 +2,7 @@
import logging
import re
from datetime import date
from datetime import date, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,6 +21,15 @@ REASON_MANUAL = "manual" # an operator decided
# 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).
@@ -178,7 +187,12 @@ async def confirm_delisting(
try:
async with _sec_client_factory() as client:
filing = await client.delisting_filing(ticker.cik)
# 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)
@@ -186,10 +200,13 @@ async def confirm_delisting(
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=filing["filing_date"], reason=REASON_FORM_25
db, normalised, delisted_on=effective, reason=REASON_FORM_25
):
return filing["filing_date"]
return effective
return None