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:
|
) -> bool:
|
||||||
"""Record that a symbol stopped trading. True if this changed anything.
|
"""Record that a symbol stopped trading. True if this changed anything.
|
||||||
|
|
||||||
Idempotent: re-marking an already-delisted symbol is a no-op, so the
|
Idempotent, so the staleness path can call it every run without churning the
|
||||||
staleness path can call it on every run without churning the row or
|
row: re-marking is a no-op. The one exception is an SEC confirmation landing
|
||||||
re-emitting events.
|
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()
|
normalised = symbol.strip().upper()
|
||||||
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||||
@@ -116,6 +118,10 @@ async def mark_delisted(
|
|||||||
if ticker is None:
|
if ticker is None:
|
||||||
raise NotFoundError(f"Ticker not found: {normalised}")
|
raise NotFoundError(f"Ticker not found: {normalised}")
|
||||||
if ticker.delisted_on is not None:
|
if ticker.delisted_on is not None:
|
||||||
|
upgrading = (
|
||||||
|
reason == REASON_FORM_25 and ticker.delisted_reason != REASON_FORM_25
|
||||||
|
)
|
||||||
|
if not upgrading:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -158,7 +164,11 @@ async def confirm_delisting(
|
|||||||
normalised = symbol.strip().upper()
|
normalised = symbol.strip().upper()
|
||||||
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||||
ticker = result.scalar_one_or_none()
|
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
|
return None
|
||||||
# No bars at all is an ingestion problem, not evidence of a delisting.
|
# No bars at all is an ingestion problem, not evidence of a delisting.
|
||||||
if last_bar is None:
|
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(
|
assert await ticker_service.confirm_delisting(
|
||||||
session, "EA", last_bar=None, today=date(2026, 8, 11)
|
session, "EA", last_bar=None, today=date(2026, 8, 11)
|
||||||
) is None
|
) 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