Source code for trader.marketdata.yfinance_provider

"""yfinance implementation of `MarketDataProvider`.

Both methods go through `Ticker.history()`: `fast_info`'s keys have changed
across yfinance releases, whereas the history frame's columns have been
stable. `pandas` objects are converted to domain `Bar`/`Quote` here and never
leave this module.
"""

from collections.abc import Callable
from datetime import UTC, date, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation

from trader.domain import Bar, Quote
from trader.errors import MarketDataError, NonRetryableMarketDataError

__all__ = ["YFinanceProvider"]


def _default_ticker_factory(symbol: str) -> object:
    """Build a real `yfinance.Ticker`, imported lazily to keep imports cheap."""
    import yfinance

    return yfinance.Ticker(symbol)


_PRICE_PRECISION = Decimal("0.0001")


def _to_decimal(value: object, field: str) -> Decimal:
    """Convert a pandas/numpy scalar to a finite `Decimal`, rounded to 4 places.

    Going via `str` avoids binary-float artifacts (yfinance hands back values
    like `190.24000549316406`). Do NOT use `Decimal.normalize()` here: it
    turns `10.0` into `1E+1`, which is numerically equal but prints wrong.

    The finiteness check is not optional: `str(numpy.nan)` is `'nan'`,
    `Decimal('nan')` is a quiet NaN, and `quantize` propagates it silently —
    so a gap in the data would otherwise become a NaN *price* that persists
    into a money column, compares equal to nothing, and raises
    `InvalidOperation` on any ordering comparison far from the cause.
    """
    try:
        raw = Decimal(str(value))
    except (InvalidOperation, TypeError, ValueError) as exc:
        raise MarketDataError(
            f"yfinance returned an unparsable {field}: {value!r}"
        ) from exc

    if not raw.is_finite():
        raise MarketDataError(f"yfinance returned a non-finite {field}: {value!r}")

    try:
        return raw.quantize(_PRICE_PRECISION, rounding=ROUND_HALF_UP)
    except InvalidOperation as exc:
        raise MarketDataError(
            f"yfinance returned a {field} too large to represent: {value!r}"
        ) from exc


def _to_utc_datetime(timestamp: object, symbol: str) -> datetime:
    """Convert a pandas Timestamp to an aware UTC `datetime`.

    yfinance returns tz-naive indices for some intervals. A naive value is
    treated as UTC rather than passed along, because `UtcDateTime` (the
    persistence column type) refuses naive datetimes — and failing here,
    at the boundary where the data enters the system, is far cheaper to
    diagnose than failing later at insert time.

    `NaT` gets the same treatment for the same reason. `pd.NaT.to_pydatetime()`
    returns `NaT` again rather than raising, and `NaT.tzinfo` is None, so a
    missing timestamp would otherwise be relabelled as UTC and travel all the
    way to an insert before anything objected — with nothing left to say which
    symbol it came from.

    The `!=` self-comparison is the detection, not `isinstance`: `NaTType`
    subclasses `datetime`, so `isinstance(pd.NaT, datetime)` is True. Like a
    NaN, `NaT` is the only datetime not equal to itself, and testing that keeps
    pandas out of this module's imports.
    """
    converted = timestamp.to_pydatetime()
    if converted != converted:  # noqa: PLR0124 - NaT/NaN self-inequality
        raise MarketDataError(
            f"yfinance returned an unusable timestamp for {symbol}: {timestamp!r}"
        )
    if converted.tzinfo is None:
        return converted.replace(tzinfo=UTC)
    return converted.astimezone(UTC)


[docs] class YFinanceProvider: """Market data backed by yfinance. Args: ticker_factory: Builds the per-symbol data object. Overridden in tests so no network call is made. """ def __init__(self, ticker_factory: Callable[[str], object] | None = None) -> None: self._ticker_factory = ticker_factory or _default_ticker_factory def _history_frame(self, symbol: str, **kwargs: object) -> object: # The adjustment basis is pinned here rather than at each call site, so # one place decides it and a new caller cannot forget — the same reason # `refresh_tail` lives in `BarCache.__init__`. # # Stored bars are the *adjusted* series: BAC's 2025-06-30 close is # 46.3112 adjusted against a raw 47.32. yfinance supplies that by # default today, so an upgrade that flipped the default would write raw # prices into a table full of adjusted ones. `save_bars` skips # timestamps it already holds, so the two bases would coexist in one # series with an invisible step at the join — enough to manufacture or # suppress a breakout, with no error to name it. # # `setdefault`, not a literal: a caller that deliberately wants the raw # series can still ask, and would otherwise hit a duplicate-keyword # TypeError. kwargs.setdefault("auto_adjust", True) try: return self._ticker_factory(symbol).history(**kwargs) except Exception as exc: # noqa: BLE001 - yfinance raises many types raise MarketDataError(f"Failed to fetch data for {symbol}: {exc}") from exc
[docs] def get_quote(self, symbol: str) -> Quote: """Return the latest price, derived from the most recent 1-minute bar.""" ticker = symbol.strip().upper() frame = self._history_frame(ticker, period="1d", interval="1m") if frame.empty: raise MarketDataError(f"No quote data returned for {ticker}.") last = frame.iloc[-1] return Quote( symbol=ticker, price=_to_decimal(last["Close"], "close"), as_of=_to_utc_datetime(frame.index[-1], ticker), )
[docs] def get_history( self, symbol: str, start: date, end: date, interval: str = "1d" ) -> list[Bar]: """Return OHLCV bars between `start` and `end`, both inclusive. **yfinance's `end` is exclusive; this Protocol's is not.** The translation happens here, at the adapter boundary, because that is the only place that knows about yfinance's convention — pushing it onto callers would mean every one of them adding a day for reasons that belong to a library they are not supposed to know about. Passing `end` straight through cost the final day of every fetch. Over a year that is nearly invisible: a 2024 backtest cached 251 bars, the number went into the project's acceptance evidence, and nobody noticed that 2024 had 252 trading days. Over a *one-day* window it returns nothing at all, so the provider raised — which is what made every strategy record an `error` decision on the daemon's first real run, with 400 days of perfectly good bars already in the cache. """ ticker = symbol.strip().upper() frame = self._history_frame( ticker, start=start, end=end + timedelta(days=1), interval=interval ) if frame.empty: raise MarketDataError( f"No historical data returned for {ticker} between {start} and {end}." ) bars: list[Bar] = [] for timestamp, row in frame.iterrows(): # The extra day above is yfinance's bound, not the caller's window. # Returning a bar past `end` would hand a backtest a bar outside # the range it asked for and let it report a return that window # never produced. if _to_utc_datetime(timestamp, ticker).date() > end: continue # `int(numpy.nan)` raises ValueError, `int(None)` TypeError, and # `int(float("inf"))` OverflowError; all three must surface as # MarketDataError rather than escaping the CLI's TraderError # handler as a traceback. try: volume = int(row["Volume"]) except (TypeError, ValueError, OverflowError) as exc: raise MarketDataError( f"yfinance returned an unusable volume for {ticker}: " f"{row['Volume']!r}" ) from exc # `Bar.__post_init__` raises a bare `ValueError` (not a # `TraderError`) on an internally inconsistent OHLC row — the # same escape hazard as the volume conversion above. yfinance # has been observed serving exactly this for today's still- # settling bar (open outside [low, high]), which otherwise # escapes `_process_symbol`'s `except TraderError` and fails the # whole cycle rather than just this one symbol (issue #97). # `NonRetryableMarketDataError`, not plain `MarketDataError`: # this is a deterministic content problem, confirmed live to # reproduce identically on retry and even 17 minutes later # (issue #99) — the resilience layer's 2s+4s backoff can never # help, only waste it, once per strategy per cycle. try: bars.append( Bar( symbol=ticker, timestamp=_to_utc_datetime(timestamp, ticker), open=_to_decimal(row["Open"], "open"), high=_to_decimal(row["High"], "high"), low=_to_decimal(row["Low"], "low"), close=_to_decimal(row["Close"], "close"), volume=volume, ) ) except ValueError as exc: raise NonRetryableMarketDataError( f"yfinance returned an internally inconsistent bar for " f"{ticker}: {exc}" ) from exc return bars