"""News feed service for Prism v2 — Finnhub company news + sentiment, FMP fallback.""" from __future__ import annotations import hashlib import logging import os from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import urlparse import httpx from cachetools import TTLCache, cached FINNHUB_BASE = "https://finnhub.io/api/v1" FMP_BASE = "https://financialmodelingprep.com/api/v3" NEWS_CACHE: TTLCache = TTLCache(maxsize=128, ttl=600) logger = logging.getLogger(__name__) POSITIVE_WORDS = [ "beats", "surges", "rises", "gains", "profit", "record", "upgrade", "buy", "outperform", "growth", "strong", "higher", "rally", "raises", "expands", "upside", ] NEGATIVE_WORDS = [ "misses", "falls", "drops", "loss", "cut", "downgrade", "sell", "underperform", "weak", "lower", "decline", "warning", "layoff", "lawsuit", "probe", "cuts", ] def _finnhub_api_key() -> str: return os.getenv("FINNHUB_API_KEY", "") def _fmp_api_key() -> str: return os.getenv("FMP_API_KEY", "") def _today_window(days: int = 30) -> tuple[str, str]: end = datetime.now(timezone.utc).date() start = end - timedelta(days=days) return start.isoformat(), end.isoformat() def _http_get_json(url: str, params: dict[str, Any] | None = None, timeout: float = 10.0) -> Any: try: with httpx.Client(timeout=timeout) as client: resp = client.get(url, params=params) resp.raise_for_status() return resp.json() except Exception as exc: # noqa: BLE001 logger.warning("news upstream request failed: %s - %s", url, exc) return None def _finnhub_company_news(symbol: str) -> list[dict]: start, end = _today_window(30) data = _http_get_json( f"{FINNHUB_BASE}/company-news", params={"symbol": symbol.upper(), "from": start, "to": end, "token": _finnhub_api_key()}, ) if not isinstance(data, list): return [] return data[:100] def _finnhub_news_sentiment(symbol: str) -> dict | None: data = _http_get_json( f"{FINNHUB_BASE}/news-sentiment", params={"symbol": symbol.upper(), "token": _finnhub_api_key()}, ) if isinstance(data, dict): return data return None def _fmp_stock_news(symbol: str) -> list[dict]: data = _http_get_json( f"{FMP_BASE}/stock_news", params={"tickers": symbol.upper(), "limit": 100, "apikey": _fmp_api_key()}, ) if not isinstance(data, list): return [] return data[:100] def _classify_sentiment(article: dict) -> str: headline = str(article.get("headline") or article.get("title") or "").lower() summary = str(article.get("summary") or article.get("text") or "").lower() text = headline + " " + summary pos = sum(1 for w in POSITIVE_WORDS if w in text) neg = sum(1 for w in NEGATIVE_WORDS if w in text) if pos > neg: return "bullish" if neg > pos: return "bearish" return "neutral" def _normalize_source(raw: Any) -> str: text = str(raw or "").strip().lower() if text.startswith("www."): text = text[4:] # If it looks like a domain, drop TLD. if "." in text: text = text.split(".")[0] return text or "unknown" def _parse_published_at(raw: Any) -> str | None: if raw is None: return None if isinstance(raw, (int, float)): try: dt = datetime.fromtimestamp(float(raw), tz=timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") except Exception: # noqa: BLE001 return None text = str(raw).strip() if not text: return None # ISO strings norm = text.replace("Z", "+00:00") try: dt = datetime.fromisoformat(norm) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) else: dt = dt.astimezone(timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") except Exception: # noqa: BLE001 pass # FMP-style "YYYY-MM-DD HH:MM:SS" for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): try: dt = datetime.strptime(text[: len(fmt)], fmt).replace(tzinfo=timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") except Exception: # noqa: BLE001 pass return None def _is_safe_url(url: Any) -> bool: if not url: return False parsed = urlparse(str(url)) return parsed.scheme in ("http", "https") def _build_item(article: dict) -> dict | None: title = str(article.get("headline") or article.get("title") or "").strip() if not title: title = "Untitled item" summary = str(article.get("summary") or article.get("text") or "").strip() source = _normalize_source(article.get("source") or article.get("site")) url = article.get("url") or article.get("newsURL") if not _is_safe_url(url): return None published_at = _parse_published_at(article.get("datetime") or article.get("publishedDate")) if published_at is None: return None item_id = hashlib.sha1(str(url).encode("utf-8")).hexdigest()[:16] return { "id": item_id, "title": title, "summary": summary, "source": source, "url": str(url), "sentiment": _classify_sentiment(article), "published_at": published_at, } def _build_aggregate(raw: dict | None) -> dict | None: if not raw: return None buzz = raw.get("buzz") or {} score = raw.get("sentiment") or {} weekly = buzz.get("articlesInLastWeek") bull = score.get("bullishPercent") bear = score.get("bearishPercent") neutral = score.get("neutralPercent") if weekly is None and bull is None and bear is None and neutral is None: return None return { "buzz_articles_last_week": int(weekly) if weekly is not None else 0, "bullish_pct": float(bull) if bull is not None else 0.0, "bearish_pct": float(bear) if bear is not None else 0.0, "neutral_pct": float(neutral) if neutral is not None else 0.0, } @cached(NEWS_CACHE, key=lambda symbol: symbol.upper()) def get_news(symbol: str) -> dict: """Return recent news articles and aggregate sentiment for a ticker.""" symbol = symbol.upper() provider = "finnhub" raw_articles = _finnhub_company_news(symbol) if not raw_articles: raw_articles = _fmp_stock_news(symbol) provider = "fmp" if raw_articles else None items = [] for article in raw_articles: if not article: continue item = _build_item(article) if item: items.append(item) items.sort(key=lambda i: i["published_at"], reverse=True) items = items[:100] aggregate = _build_aggregate(_finnhub_news_sentiment(symbol)) return { "items": items, "aggregate": aggregate, "provider": provider, "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), }