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)