From d950fcf70e09ac12d3bffc481bd92f9b92b39c43 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 11 Aug 2026 14:33:32 +0200 Subject: [PATCH] fix(tickers): let an SEC confirmation upgrade a manual delisting mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/services/ticker_service.py | 20 ++++++++--- tests/unit/test_ticker_delisting.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/app/services/ticker_service.py b/app/services/ticker_service.py index 6cf58d3..9c58c15 100644 --- a/app/services/ticker_service.py +++ b/app/services/ticker_service.py @@ -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,7 +118,11 @@ async def mark_delisted( if ticker is None: raise NotFoundError(f"Ticker not found: {normalised}") if ticker.delisted_on is not None: - return False + upgrading = ( + reason == REASON_FORM_25 and ticker.delisted_reason != REASON_FORM_25 + ) + if not upgrading: + return False await db.execute( update(Ticker) @@ -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: diff --git a/tests/unit/test_ticker_delisting.py b/tests/unit/test_ticker_delisting.py index 957c088..08ce190 100644 --- a/tests/unit/test_ticker_delisting.py +++ b/tests/unit/test_ticker_delisting.py @@ -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"]