Files
signal-platform/tests/unit/test_ticker_universe_service.py
T
dennisthiessenandClaude Opus 5 3e83d63b05 chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts +
DoltHub earnings are already the live source for `fundamental_data`. This
removes everything the legacy path still occupied.

Gone: the three providers and their config/env keys; the weekly
`fundamental_collector` job; the cutover toggle (SEC + Dolt is now the
unconditional path, so `off` can no longer silently freeze scoring inputs); the
A5 parity report, whose deltas became structurally zero once the candidate
builder started writing the table it compared against; and the FMP tier of
universe bootstrap.

Two behavioral notes:

- Disabling **SEC Fundamentals Import** now stops the SEC network fetch only.
  The local cache refresh moved outside the job-enable check, because candidates
  also derive from daily closes and earnings events — freezing those on an
  ingestion pause would stale scoring with no fallback left to recover from.
- `/ingestion/fetch?sources=fundamentals` still accepts the key and reports
  `skipped`; there is no per-ticker fetch any more.

Migration 029 does not blanket-delete the leftover settings rows. Migrations run
before the service restart, and pre-A6 code reads an absent `job_*_enabled` row
as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe
values (hidden in Admin) and only the inert three are deleted. Removing the
provider keys from the production `.env` is the matching rollout step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:19:28 +02:00

147 lines
5.0 KiB
Python

"""Unit tests for ticker_universe_service bootstrap logic."""
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
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.settings import SystemSetting
from app.models.ticker import Ticker
from app.services import ticker_universe_service
from app.services.ticker_universe_service import _extract_wiki_symbols, _normalise_symbols
_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
@pytest.mark.asyncio
async def test_bootstrap_universe_adds_missing_symbols(session: AsyncSession, monkeypatch: pytest.MonkeyPatch):
session.add(Ticker(symbol="AAPL"))
await session.commit()
async def _fake_fetch(_db: AsyncSession, _universe: str) -> tuple[list[str], str]:
return ["AAPL", "MSFT", "NVDA"], "test"
monkeypatch.setattr(ticker_universe_service, "fetch_universe_symbols", _fake_fetch)
result = await ticker_universe_service.bootstrap_universe(session, "sp500")
assert result["added"] == 2
assert result["already_tracked"] == 1
assert result["deleted"] == 0
assert result["source"] == "test"
assert set(result["added_symbols"]) == {"MSFT", "NVDA"}
rows = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol.asc()))
assert list(rows.scalars().all()) == ["AAPL", "MSFT", "NVDA"]
@pytest.mark.asyncio
async def test_bootstrap_universe_prunes_missing_symbols(session: AsyncSession, monkeypatch: pytest.MonkeyPatch):
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="MSFT"), Ticker(symbol="TSLA")])
await session.commit()
async def _fake_fetch(_db: AsyncSession, _universe: str) -> tuple[list[str], str]:
return ["AAPL", "MSFT"], "test"
monkeypatch.setattr(ticker_universe_service, "fetch_universe_symbols", _fake_fetch)
result = await ticker_universe_service.bootstrap_universe(
session,
"sp500",
prune_missing=True,
)
assert result["added"] == 0
assert result["already_tracked"] == 2
assert result["deleted"] == 1
rows = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol.asc()))
assert list(rows.scalars().all()) == ["AAPL", "MSFT"]
@pytest.mark.asyncio
async def test_fetch_universe_symbols_uses_cached_snapshot_when_live_sources_fail(
session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
):
session.add(
SystemSetting(
key="ticker_universe_cache_sp500",
value=json.dumps({"symbols": ["AAPL", "MSFT"], "source": "test"}),
)
)
await session.commit()
async def _fake_public(_universe: str):
return [], ["public failed"], None
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public)
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
assert symbols == ["AAPL", "MSFT"]
assert source == "cache"
@pytest.mark.asyncio
async def test_fetch_universe_symbols_uses_seed_when_live_and_cache_fail(
session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
):
async def _fake_public(_universe: str):
return [], ["public failed"], None
monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public)
symbols, source = await ticker_universe_service.fetch_universe_symbols(session, "sp500")
assert "AAPL" in symbols
assert len(symbols) > 10
assert source == "seed"
# Snippet shaped like current Wikipedia NyseSymbol / exchange link markup.
_SAMPLE_WIKI_HTML = """
<td><a class="external text"
data-mw='{"parts":[{"template":{"target":{"wt":"NyseSymbol","href":"./Template:NyseSymbol"},"params":{"1":{"wt":"BNY"}},"i":0}}]}'
>BNY</a></td>
<td><a href="https://www.nyse.com/quote/XNYS:DVN">DVN</a></td>
<td><a href="https://www.nasdaq.com/market-activity/stocks/aapl">AAPL</a></td>
<td><a href="https://www.nyse.com/quote/XNYS:MMM">MMM</a></td>
"""
def test_extract_wiki_symbols_finds_bny_and_exchange_links():
raw = _extract_wiki_symbols(_SAMPLE_WIKI_HTML)
symbols = set(_normalise_symbols(raw))
assert "BNY" in symbols
assert "DVN" in symbols
assert "AAPL" in symbols
assert "MMM" in symbols
assert "BK" not in symbols
def test_legacy_td_anchor_still_works():
html = '<tr><td><a href="/wiki/foo">BK</a></td></tr>'
symbols = set(_normalise_symbols(_extract_wiki_symbols(html)))
assert "BK" in symbols