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")), )