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:
@@ -44,12 +44,30 @@ def _submissions(forms: list[str], dates: list[str]) -> dict:
|
||||
return {
|
||||
"cik": 712515,
|
||||
"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:
|
||||
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 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(
|
||||
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()
|
||||
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:
|
||||
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():
|
||||
@@ -197,10 +217,10 @@ async def test_sec_confirmation_upgrades_a_manual_mark(session: AsyncSession, mo
|
||||
)
|
||||
assert await ticker_service.confirm_delisting(
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
@@ -232,3 +252,116 @@ async def test_ohlcv_priority_ordering_skips_delisted(session: AsyncSession):
|
||||
symbols = await _get_ohlcv_priority_tickers(session)
|
||||
assert "EA" not in symbols
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user