Source code for trader.marketdata.earnings

"""The next known earnings date for a symbol.

Issue #89. Mirrors `marketdata/analysts.py`'s shape exactly (the pattern
issue #32 established): a Protocol, a yfinance-backed implementation, and a
per-process cache so "no upcoming earnings" for an ETF is derived once, not
every ~15-minute cycle forever (issue #100's discipline, applied here from
the start rather than retrofitted).

**Implementation-time spike (issue #89's open question): `.calendar` vs.
`.get_earnings_dates()`.** Tried both against AAPL, TSLA, MSFT and SPY on
2026-08-31. `.get_earnings_dates()` raises `ImportError: Missing optional
dependency 'lxml'` in this project's environment — it scrapes and parses an
HTML table via `pandas.read_html`, which needs `lxml` and this project does
not carry as a dependency (nor should it, for one optional yfinance code
path). `.calendar` needs no extra dependency: it is yfinance's structured
`quoteSummary` JSON surface, same family as `.info` (already used by
`analysts.py`), and returned a clean `dict` for every ticker tried —
`{'Earnings Date': [date(2026, 10, 29)], ...}` for AAPL/TSLA/MSFT, and an
empty `{}` for SPY (an ETF, no earnings — not an exception, not a 404,
matching the "ETFs get a clean empty answer" shape `.recommendations`
already established for analyst coverage). `.calendar` is what this module
uses.

Absent/unknown is `None`, never a fabricated date — same "missing is not
zero" discipline `AnalystOpinion` uses for coverage.
"""

from collections.abc import Callable
from datetime import date
from typing import Protocol, runtime_checkable

from trader.errors import MarketDataError

__all__ = ["EarningsProvider", "YFinanceEarningsProvider"]


[docs] @runtime_checkable class EarningsProvider(Protocol): """The next known earnings date for a symbol, or `None`."""
[docs] def get_next_earnings_date(self, symbol: str) -> date | None: """Raises `MarketDataError` if the lookup itself fails.""" ...
def _default_ticker_factory(symbol: str) -> object: import yfinance return yfinance.Ticker(symbol)
[docs] class YFinanceEarningsProvider: """Next earnings date from yfinance's `.calendar`. No pandas type escapes. Caches a successful answer per symbol for this instance's lifetime (issue #100, applied here the same way `YFinanceAnalystProvider` uses it) — "no upcoming earnings" for an ETF is a structural fact, not something that changes cycle to cycle, and a real company's earnings date does not move minute to minute either. Deliberately no TTL, the same accepted tradeoff `analysts.py` documents: a symbol whose earnings date is later announced or revised stays cached until the process restarts. Never cached on an exception, so a transient fetch failure is retried fresh next call rather than permanently misremembered. """ def __init__(self, ticker_factory: Callable[[str], object] | None = None) -> None: self._ticker_factory = ticker_factory or _default_ticker_factory self._cache: dict[str, date | None] = {}
[docs] def get_next_earnings_date(self, symbol: str) -> date | None: ticker = symbol.strip().upper() if ticker in self._cache: return self._cache[ticker] result = self._fetch_next_earnings_date(ticker) self._cache[ticker] = result return result
def _fetch_next_earnings_date(self, ticker: str) -> date | None: try: instrument = self._ticker_factory(ticker) calendar = instrument.calendar except Exception as exc: # noqa: BLE001 - yfinance raises many types raise MarketDataError( f"Failed to fetch earnings calendar for {ticker}: {exc}" ) from exc # An ETF (SPY) returns `{}`, not an exception — no earnings, not a # failure. A test double or a future minimal fake returning `None` # degrades the same way, matching `AnalystOpinion`'s "missing is not # a failure" contract. if not calendar: return None try: raw_dates = calendar.get("Earnings Date") except AttributeError as exc: # `calendar` claimed to be truthy but is not dict-shaped — an # unanticipated yfinance response shape, not a network failure, # but still something this call must not silently misread as # "no earnings". Raised, same as a malformed `.recommendations` # row in `analysts.py`. raise MarketDataError( f"yfinance returned an unusable calendar for {ticker}: {exc}" ) from exc if not raw_dates: return None # Yahoo's own estimate window: usually one date, sometimes a # [start, end] pair bracketing the same upcoming report. Either way # this is one future event, and the soonest bound is "the next # earnings date" — never multiple distinct future events. Anything # that is not a real `date` (a stray `None`, a string) is dropped # rather than raised: this is advisory context, and one malformed # entry alongside a good one must not cost the good one. candidates = [d for d in raw_dates if isinstance(d, date)] if not candidates: return None return min(candidates)