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)