You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

210 lines
6.9 KiB
Python

"""
General-purpose financial news RSS adapter.
Pulls a small set of live general feeds (Yahoo Finance, CNBC, MarketWatch,
Seeking Alpha) and runs every headline through the EntityResolver to attach
matched ticker symbols. Replaces the previous per-symbol pull strategy.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional
from urllib.parse import urlparse
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.http_client import get_http_session
from app.models.overlay_raw_event import OverlayHeadlineEvent
from app.services.overlay.entity_resolver import EntityResolver
logger = logging.getLogger(__name__)
# General financial-news feeds. Each one returns a stream of latest headlines;
# we let EntityResolver decide which tickers each headline mentions.
_GENERAL_FEEDS: List[str] = [
"https://finance.yahoo.com/news/rssindex",
"https://www.cnbc.com/id/100003114/device/rss/rss.html", # CNBC Top News
"https://www.cnbc.com/id/15839135/device/rss/rss.html", # CNBC Markets
"https://feeds.content.dowjones.io/public/rss/mw_topstories",
"https://seekingalpha.com/market_currents.xml",
]
_PUBLISHER_DOMAINS: Dict[str, str] = {
"fool.com": "Motley Fool",
"finance.yahoo.com": "Yahoo Finance",
"news.yahoo.com": "Yahoo News",
"yahoo.com": "Yahoo Finance",
"bloomberg.com": "Bloomberg",
"reuters.com": "Reuters",
"wsj.com": "Wall Street Journal",
"cnbc.com": "CNBC",
"marketwatch.com": "MarketWatch",
"barrons.com": "Barron's",
"ft.com": "Financial Times",
"investors.com": "Investor's Business Daily",
"forbes.com": "Forbes",
"businessinsider.com": "Business Insider",
"seekingalpha.com": "Seeking Alpha",
"zacks.com": "Zacks",
"thestreet.com": "TheStreet",
"benzinga.com": "Benzinga",
"investorplace.com": "InvestorPlace",
"morningstar.com": "Morningstar",
"investopedia.com": "Investopedia",
"businesswire.com": "Business Wire",
"prnewswire.com": "PR Newswire",
"globenewswire.com": "GlobeNewswire",
"apnews.com": "Associated Press",
"gurufocus.com": "GuruFocus",
"247wallst.com": "24/7 Wall St.",
"simplywall.st": "Simply Wall St",
"kiplinger.com": "Kiplinger",
"tipranks.com": "TipRanks",
"fortune.com": "Fortune",
"marketbeat.com": "MarketBeat",
"barchart.com": "Barchart",
}
def _publisher_from_link(link: Optional[str]) -> Optional[str]:
if not link:
return None
try:
host = (urlparse(link).hostname or "").lower().lstrip(".")
except Exception:
return None
if host.startswith("www."):
host = host[4:]
if host in _PUBLISHER_DOMAINS:
return _PUBLISHER_DOMAINS[host]
parts = host.split(".")
if len(parts) >= 2:
apex = ".".join(parts[-2:])
return _PUBLISHER_DOMAINS.get(apex, apex)
return host or None
def _entry_publisher(entry) -> Optional[str]:
"""Yahoo rssindex carries publisher in a <source> child; everything else
we infer from the article URL."""
src = getattr(entry, "source", None)
if isinstance(src, dict):
title = src.get("title")
if title:
return title
elif isinstance(src, str) and src.strip():
return src.strip()
return _publisher_from_link(getattr(entry, "link", None))
class YahooRSSAdapter:
"""Pull general financial news feeds and resolve tickers from titles."""
def __init__(self):
self.resolver = EntityResolver()
async def fetch_feed(self, url: str) -> List[Dict]:
try:
import feedparser
except ImportError:
logger.error("feedparser not installed; RSS adapter inactive")
return []
try:
session = await get_http_session()
headers = {
"User-Agent": "Mozilla/5.0 StockOracle/1.0",
"Accept": "application/rss+xml, application/xml, text/xml",
}
async with session.get(
url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200:
logger.warning(f"RSS non-200 from {url}: {resp.status}")
return []
text = await resp.text()
except Exception as e:
logger.error(f"RSS fetch error for {url}: {e}")
return []
feed = feedparser.parse(text)
out: List[Dict] = []
for entry in feed.entries:
link = getattr(entry, "link", None)
guid = getattr(entry, "id", None) or link
title = getattr(entry, "title", "")
if not guid or not title:
continue
published = getattr(entry, "published_parsed", None)
if published:
try:
pub_dt = datetime(*published[:6], tzinfo=timezone.utc)
except Exception:
pub_dt = datetime.now(timezone.utc)
else:
pub_dt = datetime.now(timezone.utc)
out.append({
"guid": guid,
"title": title,
"publisher": _entry_publisher(entry),
"published_at": pub_dt,
})
return out
async def collect(self, db: AsyncSession, **_ignored) -> int:
"""Pull all general feeds, dedupe, resolve, persist."""
await self.resolver.load_aliases(db)
sem = asyncio.Semaphore(8)
async def _fetch_one(url: str) -> List[Dict]:
async with sem:
return await self.fetch_feed(url)
results = await asyncio.gather(
*[_fetch_one(u) for u in _GENERAL_FEEDS], return_exceptions=True
)
# Cross-feed deduplication by guid (some headlines syndicate across
# multiple aggregators).
seen: Dict[str, Dict] = {}
for r in results:
if isinstance(r, Exception) or not isinstance(r, list):
continue
for entry in r:
seen.setdefault(entry["guid"], entry)
inserted = 0
for guid, entry in seen.items():
existing = await db.execute(
select(OverlayHeadlineEvent.id).where(
OverlayHeadlineEvent.article_guid == guid
)
)
if existing.first():
continue
matched = self.resolver.resolve_from_title(entry["title"])
event = OverlayHeadlineEvent(
article_guid=guid,
title=entry["title"],
publisher=entry.get("publisher"),
published_at=entry["published_at"],
matched_symbols=matched,
)
db.add(event)
inserted += 1
if inserted:
await db.commit()
logger.info(f"RSS: inserted {inserted} new headline events")
return inserted