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