fix(tickers): let an SEC confirmation upgrade a manual delisting mark
mark_delisted returned early on any already-delisted row, so the sequence an operator actually hits — mark EA by hand today, Form 25-NSE surfaces three days later dated 2026-08-04 — left the estimated date and "manual" reason in place permanently. Form 25 carries the real effective date, so it now replaces an operator's estimate; a confirmed row is never downgraded or re-probed. Also cover _get_ohlcv_priority_tickers, the one place active_only wraps a compound select rather than a bare one — the unit suite reached none of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,9 +106,11 @@ async def mark_delisted(
|
||||
) -> 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.
|
||||
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))
|
||||
@@ -116,6 +118,10 @@ async def mark_delisted(
|
||||
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(
|
||||
@@ -158,7 +164,11 @@ async def confirm_delisting(
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -181,3 +181,54 @@ async def test_confirm_delisting_waits_before_spending_a_request(session: AsyncS
|
||||
assert await ticker_service.confirm_delisting(
|
||||
session, "EA", last_bar=None, today=date(2026, 8, 11)
|
||||
) is None
|
||||
|
||||
|
||||
async def test_sec_confirmation_upgrades_a_manual_mark(session: AsyncSession, monkeypatch):
|
||||
"""An operator's estimated date is a guess; Form 25 carries the real one."""
|
||||
session.add(Ticker(symbol="EA", cik="0000712515"))
|
||||
await session.commit()
|
||||
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 11))
|
||||
|
||||
monkeypatch.setattr(
|
||||
ticker_service,
|
||||
"_sec_client_factory",
|
||||
lambda: _sec_client(_submissions(["25-NSE"], ["2026-08-04"])),
|
||||
raising=False,
|
||||
)
|
||||
assert await ticker_service.confirm_delisting(
|
||||
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
|
||||
) == date(2026, 8, 4)
|
||||
|
||||
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
|
||||
assert row.delisted_on == date(2026, 8, 4)
|
||||
assert row.delisted_reason == ticker_service.REASON_FORM_25
|
||||
|
||||
|
||||
async def test_a_confirmed_row_is_never_reprobed(session: AsyncSession, monkeypatch):
|
||||
session.add(Ticker(symbol="EA", cik="0000712515"))
|
||||
await session.commit()
|
||||
await ticker_service.mark_delisted(
|
||||
session, "EA", delisted_on=date(2026, 8, 4),
|
||||
reason=ticker_service.REASON_FORM_25,
|
||||
)
|
||||
|
||||
def _explode():
|
||||
raise AssertionError("a SEC-confirmed row must not cost another request")
|
||||
|
||||
monkeypatch.setattr(ticker_service, "_sec_client_factory", _explode, raising=False)
|
||||
assert await ticker_service.confirm_delisting(
|
||||
session, "EA", last_bar=date(2026, 8, 4), today=date(2026, 9, 1)
|
||||
) is None
|
||||
|
||||
|
||||
async def test_ohlcv_priority_ordering_skips_delisted(session: AsyncSession):
|
||||
"""Covers the one statement where active_only wraps a compound select."""
|
||||
from app.scheduler import _get_ohlcv_priority_tickers
|
||||
|
||||
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="EA"), Ticker(symbol="MSFT")])
|
||||
await session.commit()
|
||||
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 4))
|
||||
|
||||
symbols = await _get_ohlcv_priority_tickers(session)
|
||||
assert "EA" not in symbols
|
||||
assert sorted(symbols) == ["AAPL", "MSFT"]
|
||||
|
||||
Reference in New Issue
Block a user