"""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",
)