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"]