Source code for trader.news.alpaca_news

"""Alpaca news adapter.

Alpaca's news is **100% Benzinga** and shares **zero headlines** with the
yfinance feed (0 of 10 overlap on all five symbols probed over the same 30-day
window, 2026-08-14). It is a second corpus, not a replacement for the first —
which is why this provider exists alongside `YFinanceNewsProvider` under
`MergedNewsProvider` rather than in place of it.

Two payload facts, both measured against the live API on 2026-08-15, both of
which the design would be wrong without:

- **Omitting `start` returns the whole archive**, not the current day the SDK's
  own `NewsRequest` docstring claims (272 items came back for ANNX). The
  window this adapter sends is therefore deliberate, and asserted in tests.
- **`summary` is usually empty**, because `News.summary` is typed `str` and
  Alpaca sends `""` rather than omitting the key: 0 of 24 ANNX items and 0 of
  37 ITRG items carried one over 30 days. `content` fills the gap in only 3 of
  23 and 7 of 37 cases, so it is not requested at all.

`limit` caps the **total** returned, not a page — `NewsClient.get_news`
paginates internally at 50. A limit read as a page size is what made a first
coverage probe report a uniform 50 items for all 14 symbols.
"""

import logging
from datetime import UTC, datetime, timedelta

from trader.errors import MarketDataError
from trader.news.base import ArchivedNewsItem, NewsItem

__all__ = ["AlpacaNewsProvider"]

_logger = logging.getLogger("trader.news")

_DEFAULT_LOOKBACK_HOURS = 168.0
"""A week, against a 72-hour prompt window.

Wide enough that the window is never the binding constraint on what the model
sees — `max_news_age_hours` is — and narrow enough that a symbol with a large
archive does not pay for it. `limit` does the real bounding.
"""


[docs] class AlpacaNewsProvider: """Recent news from Alpaca's news API (Benzinga).""" def __init__( self, api_key: str, api_secret: str, *, lookback_hours: float = _DEFAULT_LOOKBACK_HOURS, ) -> None: self._api_key = api_key self._api_secret = api_secret self._lookback_hours = lookback_hours def _client(self) -> object: """Build a news client. A seam, so tests need no network.""" from alpaca.data.historical.news import NewsClient return NewsClient(api_key=self._api_key, secret_key=self._api_secret)
[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. """ from alpaca.data.requests import NewsRequest now = datetime.now(UTC) request = NewsRequest( symbols=symbol, start=now - timedelta(hours=self._lookback_hours), end=now, limit=limit, sort="desc", include_content=False, ) try: response = self._client().get_news(request) articles = response.data.get("news") or [] except Exception as exc: # noqa: BLE001 - the SDK raises many types raise MarketDataError(f"Failed to fetch news for {symbol}: {exc}") from exc items: list[NewsItem] = [] for article in articles: item = self._to_item(symbol, article) if item is not None: items.append(item) # Sorted here rather than trusted from `sort="desc"`: the contract this # provider publishes is "most recent first", and a merged feed's order # is digested by `news_fingerprint`, so it cannot depend on a remote # default staying what it is today. items.sort(key=lambda item: item.published_at, reverse=True) return items[:limit]
[docs] def get_news_between( self, symbol: str, start: datetime, end: datetime ) -> list[ArchivedNewsItem]: """Every archived article for `symbol` published in `[start, end)`. The archive fetch, not the trading path: `get_recent_news` bounds itself by `lookback_hours` and a `limit` because a prompt wants the newest few items, while a backfill wants everything in a span someone named. `limit=None` is what fetches a whole window. `NewsClient.get_news` paginates internally at 50 and `limit` caps the **total** returned, so any number here silently truncates the archive — measured, that is how a first coverage probe came to report exactly 50 items for all 14 symbols. An article with no id is dropped: the id is the archive's key, and an unkeyed row would re-insert on every backfill rather than dedupe. Raises: MarketDataError: the provider call failed. Deliberately not caught here — a backfill that swallowed an outage would record the span as covered and leave a permanent hole reading as "this symbol had no news", which is the ambiguity `NewsArchiveCoverage` exists to prevent. """ from alpaca.data.requests import NewsRequest request = NewsRequest( symbols=symbol, start=start, end=end, limit=None, sort="asc", include_content=False, ) try: response = self._client().get_news(request) articles = response.data.get("news") or [] except Exception as exc: # noqa: BLE001 - the SDK raises many types raise MarketDataError( f"Failed to fetch archived news for {symbol}: {exc}" ) from exc archived: list[ArchivedNewsItem] = [] for article in articles: item = self._to_item(symbol, article) if item is None: continue article_id = getattr(article, "id", None) if article_id is None: _logger.warning( "skipping %s archived news item with no id: %r", symbol, item.title, ) continue archived.append(ArchivedNewsItem(article_id=str(article_id), item=item)) return archived
def _to_item(self, symbol: str, article: object) -> NewsItem | None: """Map one article, or `None` if it is unusable. `symbol` is the one that was **requested**, never `article.symbols[0]`: a real earnings roundup returned for ITRG listed 149 symbols beginning with ABCL, and filing it under ABCL would attach the story to a name this app never asked about. """ title = str(getattr(article, "headline", "") or "").strip() if not title: # The one field the prompt cannot do without. return None published_at = getattr(article, "created_at", None) if not isinstance(published_at, datetime): _logger.debug( "skipping %s news item with unusable created_at %r", symbol, published_at, ) return None if published_at.tzinfo is None: published_at = published_at.replace(tzinfo=UTC) summary = str(getattr(article, "summary", "") or "").strip() url = getattr(article, "url", None) return NewsItem( symbol=symbol, title=title, publisher=str(getattr(article, "source", "") or "unknown"), published_at=published_at, summary=summary or None, url=str(url) if url else None, content_type="story", )