Source code for trader.news.yfinance_news
"""yfinance news adapter.
**The payload is nested under `content`, not flat** — verified against
yfinance 1.5.2 on 2026-07-31. An earlier design draft mapped top-level keys and
would have been wrong for every field.
Measured on 20 items across AAPL and SPY: `title`, `summary`, `pubDate`,
`provider.displayName`, `canonicalUrl.url`, and `contentType` were present on
every one. `description` was empty on 18 of 20, so it is ignored entirely.
"""
import logging
from datetime import UTC, datetime
from trader.errors import MarketDataError
from trader.news.base import NewsItem
__all__ = ["YFinanceNewsProvider"]
_logger = logging.getLogger("trader.news")
[docs]
class YFinanceNewsProvider:
"""Recent news from Yahoo Finance via yfinance."""
def _ticker(self, symbol: str) -> object:
"""Build a yfinance Ticker. A seam, so tests need no network."""
import yfinance
return yfinance.Ticker(symbol)
[docs]
def get_recent_news(self, symbol: str, limit: int = 10) -> list[NewsItem]:
"""Most recent items first, capped at `limit`.
Raises:
MarketDataError: the provider call failed.
"""
try:
raw_items = self._ticker(symbol).news
except Exception as exc: # noqa: BLE001 - yfinance raises many types
raise MarketDataError(f"Failed to fetch news for {symbol}: {exc}") from exc
items: list[NewsItem] = []
for raw in raw_items or []:
item = self._to_item(symbol, raw)
if item is not None:
items.append(item)
if len(items) >= limit:
break
return items
def _to_item(self, symbol: str, raw: object) -> NewsItem | None:
"""Map one payload entry, or `None` if it is unusable.
A single malformed entry is skipped rather than failing the fetch: the
others are still worth showing the model, and news is advisory input
rather than the basis of a money calculation.
"""
content = (raw or {}).get("content") if isinstance(raw, dict) else None
if not isinstance(content, dict):
return None
title = (content.get("title") or "").strip()
if not title:
# The one field the prompt cannot do without.
return None
published_at = self._parse_time(content.get("pubDate"))
if published_at is None:
_logger.debug(
"skipping %s news item with unusable pubDate %r",
symbol,
content.get("pubDate"),
)
return None
provider = content.get("provider") or {}
canonical = content.get("canonicalUrl") or {}
summary = (content.get("summary") or "").strip()
return NewsItem(
symbol=symbol,
title=title,
publisher=str(provider.get("displayName") or "unknown"),
published_at=published_at,
summary=summary or None,
url=canonical.get("url") or None,
content_type=str(content.get("contentType") or "STORY"),
)
@staticmethod
def _parse_time(value: object) -> datetime | None:
"""Parse `2026-07-31T08:12:27Z` into tz-aware UTC.
Returns `None` rather than guessing: a naive datetime would be rejected
by `UtcDateTime` at the persistence boundary anyway, and inventing a
zone would silently corrupt the timeline.
"""
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)