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>
235 lines
8.8 KiB
Python
235 lines
8.8 KiB
Python
"""Delisting lifecycle: marking, the active_only filter, and SEC confirmation.
|
|
|
|
The behaviour under test is that a delisted symbol leaves the *live* path while
|
|
its rows stay put — deleting it instead is what makes the backtest universe
|
|
survivorship-biased, so retention is the point, not a side effect.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import AsyncGenerator
|
|
from datetime import date
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from app.database import Base
|
|
from app.models.ticker import Ticker
|
|
from app.services import ticker_service
|
|
from app.services.sec_client import SecClient
|
|
|
|
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
|
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _setup_tables() -> AsyncGenerator[None, None]:
|
|
async with _engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield
|
|
async with _engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
|
|
@pytest.fixture
|
|
async def session() -> AsyncGenerator[AsyncSession, None]:
|
|
async with _session_factory() as s:
|
|
yield s
|
|
|
|
|
|
def _submissions(forms: list[str], dates: list[str]) -> dict:
|
|
return {
|
|
"cik": 712515,
|
|
"name": "ELECTRONIC ARTS INC.",
|
|
"filings": {"recent": {"form": forms, "filingDate": dates}},
|
|
}
|
|
|
|
|
|
def _sec_client(payload: dict) -> SecClient:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, content=json.dumps(payload).encode())
|
|
|
|
return SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0)
|
|
|
|
|
|
async def test_mark_delisted_is_idempotent(session: AsyncSession):
|
|
session.add(Ticker(symbol="EA"))
|
|
await session.commit()
|
|
|
|
assert await ticker_service.mark_delisted(
|
|
session, "EA", delisted_on=date(2026, 8, 4)
|
|
) is True
|
|
# A second call must not churn the row — the staleness path retries daily.
|
|
assert await ticker_service.mark_delisted(
|
|
session, "EA", delisted_on=date(2026, 9, 1)
|
|
) is False
|
|
|
|
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
|
|
assert row.delisted_on == date(2026, 8, 4) # first date wins, not the retry
|
|
assert row.delisted_reason == ticker_service.REASON_MANUAL
|
|
|
|
|
|
async def test_clear_delisted_restores_the_symbol(session: AsyncSession):
|
|
session.add(Ticker(symbol="EA"))
|
|
await session.commit()
|
|
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 4))
|
|
|
|
assert await ticker_service.clear_delisted(session, "EA") is True
|
|
assert await ticker_service.clear_delisted(session, "EA") is False
|
|
|
|
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
|
|
assert row.delisted_on is None and row.delisted_reason is None
|
|
|
|
|
|
async def test_active_only_filters_but_the_row_survives(session: AsyncSession):
|
|
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="EA")])
|
|
await session.commit()
|
|
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 4))
|
|
|
|
active = (
|
|
await session.execute(ticker_service.active_only(select(Ticker.symbol)))
|
|
).scalars().all()
|
|
assert list(active) == ["AAPL"]
|
|
|
|
# The whole point: the row — and everything cascading off it — is still there.
|
|
everything = [t.symbol for t in await ticker_service.list_tickers(session)]
|
|
assert everything == ["AAPL", "EA"]
|
|
|
|
|
|
async def test_confirm_delisting_marks_on_a_form_25(session: AsyncSession, monkeypatch):
|
|
session.add(Ticker(symbol="EA", cik="0000712515"))
|
|
await session.commit()
|
|
monkeypatch.setattr(
|
|
ticker_service,
|
|
"_sec_client_factory",
|
|
lambda: _sec_client(_submissions(["8-K", "25-NSE"], ["2026-07-01", "2026-08-04"])),
|
|
raising=False,
|
|
)
|
|
|
|
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)
|
|
|
|
row = (await session.execute(select(Ticker).where(Ticker.symbol == "EA"))).scalar_one()
|
|
assert row.delisted_reason == ticker_service.REASON_FORM_25
|
|
|
|
|
|
async def test_confirm_delisting_leaves_a_halt_alone(session: AsyncSession, monkeypatch):
|
|
"""A halt or a rename files no Form 25 — those must keep warning, not retire."""
|
|
session.add(Ticker(symbol="SATS", cik="0000012345"))
|
|
await session.commit()
|
|
monkeypatch.setattr(
|
|
ticker_service,
|
|
"_sec_client_factory",
|
|
lambda: _sec_client(_submissions(["8-K", "10-Q"], ["2026-07-01", "2026-08-04"])),
|
|
raising=False,
|
|
)
|
|
|
|
assert await ticker_service.confirm_delisting(
|
|
session, "SATS", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
|
|
) is None
|
|
row = (await session.execute(select(Ticker).where(Ticker.symbol == "SATS"))).scalar_one()
|
|
assert row.delisted_on is None
|
|
|
|
|
|
async def test_confirm_delisting_skips_a_symbol_without_a_cik(session: AsyncSession):
|
|
"""No CIK, no SEC lookup — must not raise, and must not mark."""
|
|
session.add(Ticker(symbol="ADRX"))
|
|
await session.commit()
|
|
assert await ticker_service.confirm_delisting(
|
|
session, "ADRX", last_bar=date(2026, 8, 4), today=date(2026, 8, 11)
|
|
) is None
|
|
|
|
|
|
async def test_delisting_filing_picks_the_newest_match():
|
|
client = _sec_client(
|
|
_submissions(
|
|
["25", "8-K", "25-NSE", "15-12B"],
|
|
["2024-01-02", "2026-08-01", "2026-08-04", "2025-05-05"],
|
|
)
|
|
)
|
|
async with client as c:
|
|
found = await c.delisting_filing("0000712515")
|
|
assert found == {"form": "25-NSE", "filing_date": date(2026, 8, 4)}
|
|
|
|
|
|
async def test_delisting_filing_returns_none_without_one():
|
|
client = _sec_client(_submissions(["10-K", "8-K"], ["2026-01-02", "2026-08-01"]))
|
|
async with client as c:
|
|
assert await c.delisting_filing("0000320193") is None
|
|
|
|
|
|
async def test_confirm_delisting_waits_before_spending_a_request(session: AsyncSession, monkeypatch):
|
|
"""A one-day gap is a weekend or a hiccup. Probing every stale symbol during a
|
|
market-data outage would be one SEC request per symbol per run."""
|
|
session.add(Ticker(symbol="EA", cik="0000712515"))
|
|
await session.commit()
|
|
|
|
def _explode():
|
|
raise AssertionError("must not reach SEC before the stale threshold")
|
|
|
|
monkeypatch.setattr(ticker_service, "_sec_client_factory", _explode, raising=False)
|
|
|
|
assert await ticker_service.confirm_delisting(
|
|
session, "EA", last_bar=date(2026, 8, 10), today=date(2026, 8, 11)
|
|
) is None
|
|
# ...and no bars at all is an ingestion problem, not a delisting.
|
|
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"]
|