Source code for trader.marketdata.index_provider

"""Index membership, scraped from Wikipedia (issue #27).

Never raises past `get_members`: a scrape failure degrades to `[]`, the same
shape a discovery source degrades to when `themes: []` — `trader
scan-universe` still records whatever `analyst_consensus_history` already has,
and `discover_symbols`'s analyst-scan candidate source simply has nothing new
to check that cycle.
"""

import logging
import re
from collections.abc import Callable
from typing import ClassVar

from bs4 import BeautifulSoup

__all__ = ["WikipediaIndexProvider", "validate_symbol"]

_logger = logging.getLogger("trader.marketdata.index_provider")

_SP500_URL = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
#: Wikipedia blocks the default urllib/requests user agent on some tables.
_USER_AGENT = "claude-alpaca-trader/1.0 (+index membership scan; issue #27)"

#: 1-8 chars, uppercase letters/digits plus `.`/`-` only.
_MIN_SYMBOL_LENGTH = 1
_MAX_SYMBOL_LENGTH = 8
_ALLOWED_CHARS = re.compile(r"^[A-Z0-9.\-]+$")

#: Which header text names the symbol column. Wikipedia has used more than
#: one wording for this over time.
_SYMBOL_HEADERS = frozenset({"symbol", "ticker symbol", "ticker"})


[docs] def validate_symbol(raw: str) -> str | None: """A cleaned ticker symbol, or `None` if `raw` is not one. Wikipedia table scraping mixes years, footnote markers, and company names into the symbol column (a confirmed pattern, per `docs/ideas.md`'s "validate ticker symbols at the boundary" entry) — this is the one validator at the scrape boundary that rejects those, the same discipline `listing_gate` applies to a warrant suffix: logged, countable, never a crash reaching `discover_symbols`. """ candidate = raw.strip().upper() if not _MIN_SYMBOL_LENGTH <= len(candidate) <= _MAX_SYMBOL_LENGTH: return None if not _ALLOWED_CHARS.fullmatch(candidate): return None if not any(char.isalpha() for char in candidate): return None if candidate[0] in ".-" or candidate[-1] in ".-": return None return candidate
def _default_html_source(url: str) -> str: import requests response = requests.get(url, headers={"User-Agent": _USER_AGENT}, timeout=30) response.raise_for_status() return response.text def _symbol_column(table) -> list[str]: """Raw cell text from whichever column's header names the symbol.""" rows = table.find_all("tr") if not rows: return [] header_cells = rows[0].find_all(["th", "td"]) header_text = [cell.get_text(strip=True).lower() for cell in header_cells] try: column = next(i for i, text in enumerate(header_text) if text in _SYMBOL_HEADERS) except StopIteration: return [] raw_symbols: list[str] = [] for row in rows[1:]: cells = row.find_all(["td", "th"]) if column >= len(cells): continue raw_symbols.append(cells[column].get_text(strip=True)) return raw_symbols
[docs] class WikipediaIndexProvider: """Index constituents scraped from Wikipedia. No `bs4` type escapes.""" #: v1 supports only the S&P 500 — the spec's own Open Question #4 defers #: which other indices to a later session, and this is an operator-invoked #: tool, not something `config/watchlist.yaml` drives. _SUPPORTED: ClassVar[dict[str, str]] = {"sp500": _SP500_URL} def __init__(self, html_source: Callable[[str], str] | None = None) -> None: self._html_source = html_source or _default_html_source
[docs] def get_members(self, index_name: str) -> list[str]: """Cleaned, deduplicated symbols for `index_name`, or `[]` on failure. Raises `ValueError` only for an unsupported `index_name` — a caller programming error, not a runtime condition to degrade from. Every other failure (network, markup change, an empty page) is caught and logged; `trader scan-universe` still has a table to record. """ url = self._SUPPORTED.get(index_name) if url is None: raise ValueError( f"unsupported index {index_name!r}; supported: {sorted(self._SUPPORTED)}" ) try: html = self._html_source(url) soup = BeautifulSoup(html, "html.parser") table = soup.find("table", {"id": "constituents"}) or soup.find( "table", class_="wikitable" ) raw_symbols = _symbol_column(table) if table is not None else [] except Exception: # noqa: BLE001 - a scrape outage must not raise _logger.warning( "index_provider: scrape failed for %r", index_name, exc_info=True ) return [] members: list[str] = [] seen: set[str] = set() for raw in raw_symbols: cleaned = validate_symbol(raw) if cleaned is None: _logger.info("index_provider: rejected invalid symbol %r", raw) continue if cleaned in seen: continue seen.add(cleaned) members.append(cleaned) return members