Source code for trader.marketdata.sector
"""Sector/industry classification for a symbol (issue #65).
`docs/ideas.md`'s "Factors a stock analyst uses" inventory names sector and
industry as "not fetched — yfinance exposes both on the ticker info, so this
is an adapter addition, not research." This is that addition, shaped the same
way `marketdata/analysts.py` shapes its own yfinance `.info` read: a small,
frozen domain object, a Protocol so a caller can fake it, and a failure that
raises `MarketDataError` rather than ever returning a fabricated value.
Missing coverage is `None` on each field, never an empty string standing in
for "no risk" — an ETF (SPY) or a newly listed symbol yfinance has not yet
classified both look this way, and a caller measuring concentration must be
able to tell "not computed for this name" apart from a real sector.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from trader.errors import MarketDataError
__all__ = ["SectorInfo", "SectorProvider", "YFinanceSectorProvider"]
[docs]
@dataclass(frozen=True, slots=True)
class SectorInfo:
"""What yfinance's `.info` says about a symbol's classification.
Both fields are `None`, independently, when yfinance omits that key —
an ETF typically has neither. `None` is never coerced to `"Unknown"`
here; callers that need a display bucket (`trader/reporting/
concentration.py`) choose that label themselves, so this module stays a
plain reflection of what the provider actually said.
"""
sector: str | None
industry: str | None
[docs]
@runtime_checkable
class SectorProvider(Protocol):
"""Current sector/industry classification for a symbol."""
[docs]
def get_sector_info(self, symbol: str) -> SectorInfo:
"""Raises `MarketDataError` if the lookup itself fails."""
...
def _default_ticker_factory(symbol: str) -> object:
import yfinance
return yfinance.Ticker(symbol)
def _as_clean_str(value: object) -> str | None:
"""A non-empty, stripped `str`, or `None` for anything else.
yfinance can hand back `None`, an empty string, or (rarely) a non-string
placeholder for an unclassified symbol; all three degrade to "unknown"
rather than becoming a blank or whitespace-only sector label.
"""
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
[docs]
class YFinanceSectorProvider:
"""Sector/industry from yfinance's `.info`. No pandas type escapes.
Caches a successful answer per symbol for this instance's lifetime
(issue #100), the same reasoning and tradeoffs as
`analysts.YFinanceAnalystProvider`: an ETF's "no classification" is
structural, not something that changes cycle to cycle, and re-fetching
it forever cost a guaranteed 404 every time for exactly the symbols
this class's own docstring already knew would never have one. No TTL;
never cached on an exception.
"""
def __init__(self, ticker_factory: Callable[[str], object] | None = None) -> None:
self._ticker_factory = ticker_factory or _default_ticker_factory
self._cache: dict[str, SectorInfo] = {}
[docs]
def get_sector_info(self, symbol: str) -> SectorInfo:
ticker = symbol.strip().upper()
if ticker in self._cache:
return self._cache[ticker]
info = self._fetch_sector_info(ticker)
self._cache[ticker] = info
return info
def _fetch_sector_info(self, ticker: str) -> SectorInfo:
try:
instrument = self._ticker_factory(ticker)
info = getattr(instrument, "info", None)
except Exception as exc: # noqa: BLE001 - yfinance raises many types
raise MarketDataError(
f"Failed to fetch sector info for {ticker}: {exc}"
) from exc
info = info or {}
return SectorInfo(
sector=_as_clean_str(info.get("sector")),
industry=_as_clean_str(info.get("industry")),
)