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
+33 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access from app.dependencies import get_db, require_access
from app.models.user import User from app.models.user import User
from app.schemas.common import APIEnvelope from app.schemas.common import APIEnvelope
from app.schemas.ticker import TickerCreate, TickerResponse from app.schemas.ticker import TickerCreate, TickerDelistingUpdate, TickerResponse
from app.services import ticker_service from app.services import ticker_service
router = APIRouter(tags=["tickers"]) router = APIRouter(tags=["tickers"])
@@ -51,3 +51,35 @@ async def delete_ticker(
"""Delete a ticker and all associated data.""" """Delete a ticker and all associated data."""
await ticker_service.delete_ticker(db, symbol) await ticker_service.delete_ticker(db, symbol)
return APIEnvelope(status="success", data=None) return APIEnvelope(status="success", data=None)
@router.post("/tickers/{symbol}/delisting", response_model=APIEnvelope)
async def mark_ticker_delisted(
symbol: str,
body: TickerDelistingUpdate,
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
):
"""Retire a symbol: excluded from signals, price history kept.
The non-destructive alternative to DELETE, which cascades the history away.
"""
changed = await ticker_service.mark_delisted(
db, symbol, delisted_on=body.delisted_on, reason=ticker_service.REASON_MANUAL
)
return APIEnvelope(status="success", data={"changed": changed})
@router.delete("/tickers/{symbol}/delisting", response_model=APIEnvelope)
async def clear_ticker_delisting(
symbol: str,
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
):
"""Un-retire a symbol wrongly marked delisted.
Automatic marking is only defensible because this exists: a false positive
costs one row update rather than the price history a delete would take.
"""
changed = await ticker_service.clear_delisted(db, symbol)
return APIEnvelope(status="success", data={"changed": changed})
+6
View File
@@ -21,3 +21,9 @@ class TickerResponse(BaseModel):
delisted_reason: str | None = None delisted_reason: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class TickerDelistingUpdate(BaseModel):
delisted_on: date = Field(
..., description="Effective date the symbol stopped trading"
)
+96 -19
View File
@@ -45,12 +45,32 @@ _CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
_FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"}) _FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
# Exchange delisting (25 / 25-NSE) and registration termination (15 family). # Notification of removal from listing. "25" is issuer-filed, "25-NSE" exchange-
# Their presence is SEC confirming a security stopped trading — see # filed. The Form 15 family is deliberately absent: it ends a *reporting*
# ``SecClient.delisting_filing``. # obligation and does not mean the security stopped trading.
_DELISTING_FORMS = frozenset({ _DELISTING_FORMS = frozenset({"25", "25-NSE"})
"25", "25-NSE", "15-12B", "15-12G", "15F-12B", "15F-12G",
}) # ``descriptionClassSecurity`` is free text ("Common Stock", "Class A Common
# Stock, $0.01 par value", "6.25% Notes due 2030", "Warrants", "Depositary
# Shares"). Only a common-equity class means the ticker itself stopped trading.
_NON_COMMON_CLASS = re.compile(
r"\b(note|bond|debenture|preferred|warrant|right|unit|depositary|"
r"subordinated|debt|trust)s?\b",
re.IGNORECASE,
)
def _is_common_stock(description: str) -> bool:
"""Does this Form 25 security class describe common equity?
Requires an explicit common-stock match AND no debt/preferred/warrant marker,
so "Depositary Shares each representing 1/1000th of Preferred" cannot pass on
the word "shares" alone. Unrecognised text is rejected — a symbol is retired
on this answer, so ambiguity must not read as yes.
"""
if _NON_COMMON_CLASS.search(description):
return False
return re.search(r"\bcommon\s+(stock|share)", description, re.IGNORECASE) is not None
class SecError(ProviderError): class SecError(ProviderError):
@@ -260,23 +280,40 @@ class SecClient:
"filings": filings, "filings": filings,
} }
async def delisting_filing(self, cik: int | str) -> dict[str, Any] | None: async def delisting_filing(
"""Newest exchange-delisting / deregistration filing, or ``None``. self, cik: int | str, *, not_before: date | None = None
) -> dict[str, Any] | None:
"""Newest Form 25 removing this issuer's COMMON stock from listing.
Form 25/25-NSE strikes a security from listing; Form 15 terminates the Deliberately narrow, because the caller retires a symbol on the answer:
registration. Either is SEC confirming the security stopped trading —
which is what separates a real delisting from a multi-day halt or a
ticker rename, neither of which files one. That precision is the reason
this can mark a symbol automatically.
Reads ``filings.recent`` directly: ``submissions()`` keeps only the - **Form 25 only.** The Form 15 family terminates a reporting obligation
10-K/10-Q family, so these forms never survive its parser. (often just a class falling under the holder threshold) and is no
evidence that trading stopped.
- **Class-checked.** Form 25 is filed per security class — an issuer
delisting its notes, preferred, warrants or an ADR class while the
common keeps trading files one too. The filing's own
``descriptionClassSecurity`` is what separates those, so the primary
document is fetched and read rather than trusting the form type.
- **``not_before``** rejects a historical filing for some long-gone
class. Without it a 2019 Form 25 would retire a symbol whose bars
stopped in 2026, and stamp 2019 as the date.
Anything unreadable — no primary document (pre-2009 filings have none),
malformed XML, unrecognised class — returns ``None``. Fail closed: the
caller keeps warning instead of retiring on a guess.
Reads ``filings.recent`` directly; ``submissions()`` keeps only the
10-K/10-Q family, so Form 25 never survives its parser.
""" """
base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json") base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json")
arrays = (base.get("filings") or {}).get("recent") or {} arrays = (base.get("filings") or {}).get("recent") or {}
forms = arrays.get("form") or [] forms = arrays.get("form") or []
dates = arrays.get("filingDate") or [] dates = arrays.get("filingDate") or []
best: dict[str, Any] | None = None accessions = arrays.get("accessionNumber") or []
docs = arrays.get("primaryDocument") or []
candidates: list[tuple[date, str, str, str]] = []
for i, form in enumerate(forms): for i, form in enumerate(forms):
if form not in _DELISTING_FORMS or i >= len(dates) or not dates[i]: if form not in _DELISTING_FORMS or i >= len(dates) or not dates[i]:
continue continue
@@ -284,9 +321,49 @@ class SecClient:
filed = date.fromisoformat(dates[i]) filed = date.fromisoformat(dates[i])
except ValueError: except ValueError:
continue continue
if best is None or filed > best["filing_date"]: if not_before is not None and filed < not_before:
best = {"form": form, "filing_date": filed} continue
return best if i >= len(accessions) or not accessions[i]:
continue
candidates.append((filed, form, accessions[i], docs[i] if i < len(docs) else ""))
for filed, form, accession, _doc in sorted(candidates, reverse=True):
security = await self._form25_security_class(cik, accession)
if security is None:
continue
if not _is_common_stock(security):
continue
return {
"form": form,
"filing_date": filed,
"security_class": security,
}
return None
async def _form25_security_class(
self, cik: int | str, accession: str
) -> str | None:
"""``descriptionClassSecurity`` from a Form 25's primary XML, or None.
The rendered ``primaryDocument`` is an XSL view of this file; the raw
``primary_doc.xml`` beside it is the structured original.
"""
folder = accession.replace("-", "")
url = (
f"{_WWW}/Archives/edgar/data/{int(cik)}/{folder}/primary_doc.xml"
)
try:
body = await self.get_text(url)
except SecNotFoundError:
return None
match = re.search(
r"<descriptionClassSecurity>(.*?)</descriptionClassSecurity>",
body,
re.IGNORECASE | re.DOTALL,
)
if match is None:
return None
return " ".join(match.group(1).split()) or None
async def companyfacts(self, cik: int | str) -> dict[str, Any]: async def companyfacts(self, cik: int | str) -> dict[str, Any]:
"""Raw companyfacts JSON ({cik, entityName, facts}).""" """Raw companyfacts JSON ({cik, entityName, facts})."""
+21 -4
View File
@@ -2,7 +2,7 @@
import logging import logging
import re import re
from datetime import date from datetime import date, timedelta
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession 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. # universe at once; a real delisting is still stale days later.
MIN_STALE_DAYS_BEFORE_PROBE = 3 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(): def _sec_client_factory():
"""Build the SEC client for a delisting probe (patched in tests). """Build the SEC client for a delisting probe (patched in tests).
@@ -178,7 +187,12 @@ async def confirm_delisting(
try: try:
async with _sec_client_factory() as client: 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: except SecError:
# Never let a probe failure escalate a routine staleness warning. # Never let a probe failure escalate a routine staleness warning.
logger.warning("delisting probe failed for %s", normalised, exc_info=True) logger.warning("delisting probe failed for %s", normalised, exc_info=True)
@@ -186,10 +200,13 @@ async def confirm_delisting(
if filing is None: if filing is None:
return 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( 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 return None
+23 -2
View File
@@ -357,9 +357,26 @@ async def bootstrap_universe(
db.add(Ticker(symbol=symbol)) db.add(Ticker(symbol=symbol))
deleted_count = 0 deleted_count = 0
skipped_delisted: list[str] = []
if symbols_to_delete: if symbols_to_delete:
result = await db.execute(delete(Ticker).where(Ticker.symbol.in_(symbols_to_delete))) # A delisted row was retained on purpose — its price history is exactly
deleted_count = int(result.rowcount or 0) # what a survivorship-honest backtest needs, and the delete cascades it
# away. Pruning must not undo that. (Pruning a symbol that is merely no
# longer an index constituent still destroys history; that needs a
# tracked/membership state separate from delisting.)
protected = (
await db.execute(
select(Ticker.symbol).where(
Ticker.symbol.in_(symbols_to_delete),
Ticker.delisted_on.is_not(None),
)
)
).scalars().all()
skipped_delisted = sorted(protected)
deletable = [s for s in symbols_to_delete if s not in set(protected)]
if deletable:
result = await db.execute(delete(Ticker).where(Ticker.symbol.in_(deletable)))
deleted_count = int(result.rowcount or 0)
await db.commit() await db.commit()
@@ -378,4 +395,8 @@ async def bootstrap_universe(
"already_tracked": len(target_symbols & existing_symbols), "already_tracked": len(target_symbols & existing_symbols),
"deleted": deleted_count, "deleted": deleted_count,
"added_symbols": symbols_to_add[:50], "added_symbols": symbols_to_add[:50],
# Delisted rows a prune declined to destroy, so the caller can see the
# count did not match what they asked to remove.
"kept_delisted": skipped_delisted[:50],
"kept_delisted_count": len(skipped_delisted),
} }
+139 -6
View File
@@ -44,12 +44,30 @@ def _submissions(forms: list[str], dates: list[str]) -> dict:
return { return {
"cik": 712515, "cik": 712515,
"name": "ELECTRONIC ARTS INC.", "name": "ELECTRONIC ARTS INC.",
"filings": {"recent": {"form": forms, "filingDate": dates}}, "filings": {
"recent": {
"form": forms,
"filingDate": dates,
"accessionNumber": [f"0001354457-26-{i:06d}" for i in range(len(forms))],
"primaryDocument": ["xslF25X02/primary_doc.xml"] * len(forms),
}
},
} }
def _sec_client(payload: dict) -> SecClient: def _sec_client(payload: dict, security: str | None = "Common Stock") -> SecClient:
"""Mock submissions + the Form 25 primary document the class check reads."""
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("primary_doc.xml"):
if security is None:
return httpx.Response(404)
body = (
"<?xml version='1.0'?><notificationOfRemoval>"
f"<descriptionClassSecurity>{security}</descriptionClassSecurity>"
"</notificationOfRemoval>"
)
return httpx.Response(200, content=body.encode())
return httpx.Response(200, content=json.dumps(payload).encode()) return httpx.Response(200, content=json.dumps(payload).encode())
return SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) return SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0)
@@ -112,7 +130,8 @@ async def test_confirm_delisting_marks_on_a_form_25(session: AsyncSession, monke
marked = await ticker_service.confirm_delisting( marked = await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11) session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) )
assert marked == date(2026, 8, 4) # Removal is effective ten days after the 2026-08-04 filing, not on it.
assert marked == date(2026, 8, 14)
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one() row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
assert row.delisted_reason == ticker_service.REASON_FORM_25 assert row.delisted_reason == ticker_service.REASON_FORM_25
@@ -154,7 +173,8 @@ async def test_delisting_filing_picks_the_newest_match():
) )
async with client as c: async with client as c:
found = await c.delisting_filing("0000712515") found = await c.delisting_filing("0000712515")
assert found == {"form": "25-NSE", "filing_date": date(2026, 8, 4)} assert found["form"] == "25-NSE"
assert found["filing_date"] == date(2026, 8, 4)
async def test_delisting_filing_returns_none_without_one(): async def test_delisting_filing_returns_none_without_one():
@@ -197,10 +217,10 @@ async def test_sec_confirmation_upgrades_a_manual_mark(session: AsyncSession, mo
) )
assert await ticker_service.confirm_delisting( assert await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11) session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) == date(2026, 8, 4) ) == date(2026, 8, 14)
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one() row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
assert row.delisted_on == date(2026, 8, 4) assert row.delisted_on == date(2026, 8, 14)
assert row.delisted_reason == ticker_service.REASON_FORM_25 assert row.delisted_reason == ticker_service.REASON_FORM_25
@@ -232,3 +252,116 @@ async def test_ohlcv_priority_ordering_skips_delisted(session: AsyncSession):
symbols = await _get_ohlcv_priority_tickers(session) symbols = await _get_ohlcv_priority_tickers(session)
assert "EA" not in symbols assert "EA" not in symbols
assert sorted(symbols) == ["AAPL", "MSFT"] assert sorted(symbols) == ["AAPL", "MSFT"]
async def test_a_form_25_for_another_security_class_is_ignored(session: AsyncSession, monkeypatch):
"""Form 25 is per security class. An issuer delisting its notes, preferred or
warrants files one while the common keeps trading — retiring the ticker on
that would remove an actively traded symbol from every signal."""
session.add(Ticker(symbol="EA", cik="0000712515"))
await session.commit()
monkeypatch.setattr(
ticker_service,
"_sec_client_factory",
lambda: _sec_client(
_submissions(["25-NSE"], ["2026-08-04"]),
security="6.25% Notes due 2030",
),
raising=False,
)
assert await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) is None
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
assert row.delisted_on is None
async def test_a_stale_historical_form_25_cannot_retire_a_symbol(session: AsyncSession, monkeypatch):
"""A 2019 filing for a long-gone class must not retire a symbol whose bars
ran until 2026 — and must certainly not stamp 2019 as the date."""
session.add(Ticker(symbol="EA", cik="0000712515"))
await session.commit()
monkeypatch.setattr(
ticker_service,
"_sec_client_factory",
lambda: _sec_client(_submissions(["25"], ["2019-03-01"])),
raising=False,
)
assert await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) is None
async def test_form_15_alone_never_retires_a_symbol(session: AsyncSession, monkeypatch):
"""Form 15 ends a reporting obligation; it is not evidence trading stopped."""
session.add(Ticker(symbol="EA", cik="0000712515"))
await session.commit()
monkeypatch.setattr(
ticker_service,
"_sec_client_factory",
lambda: _sec_client(_submissions(["15-12B", "15-12G"], ["2026-08-04", "2026-08-05"])),
raising=False,
)
assert await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) is None
async def test_an_unreadable_form_25_fails_closed(session: AsyncSession, monkeypatch):
"""Pre-2009 filings have no primary_doc.xml. Unknown class must read as no."""
session.add(Ticker(symbol="EA", cik="0000712515"))
await session.commit()
monkeypatch.setattr(
ticker_service,
"_sec_client_factory",
lambda: _sec_client(_submissions(["25"], ["2026-08-04"]), security=None),
raising=False,
)
assert await ticker_service.confirm_delisting(
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
) is None
@pytest.mark.parametrize(
"description,expected",
[
("Common Stock", True),
("Class A Common Stock, $0.01 par value", True),
("Common Shares, no par value", True),
("6.25% Notes due 2030", False),
("7.5% Series B Cumulative Preferred Stock", False),
("Warrants to purchase Common Stock", False),
("Depositary Shares each representing 1/1000th interest", False),
("", False),
],
)
def test_common_stock_classification(description: str, expected: bool):
from app.services.sec_client import _is_common_stock
assert _is_common_stock(description) is expected
async def test_prune_keeps_delisted_rows(session: AsyncSession, monkeypatch):
"""A prune must not destroy rows the delisting flow deliberately retained —
their price history is the whole reason those rows still exist."""
from app.services import ticker_universe_service as tus
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="EA"), Ticker(symbol="GONE")])
await session.commit()
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 14))
async def fake_fetch(db, universe):
return ["AAPL"], "test"
monkeypatch.setattr(tus, "fetch_universe_symbols", fake_fetch)
summary = await tus.bootstrap_universe(session, "sp500", prune_missing=True)
remaining = sorted(t.symbol for t in await ticker_service.list_tickers(session))
assert remaining == ["AAPL", "EA"] # GONE pruned, EA protected
assert summary["deleted"] == 1
assert summary["kept_delisted"] == ["EA"]