summaryrefslogtreecommitdiff
path: root/backend/app/services/news_service.py
diff options
context:
space:
mode:
Diffstat (limited to 'backend/app/services/news_service.py')
-rw-r--r--backend/app/services/news_service.py221
1 files changed, 221 insertions, 0 deletions
diff --git a/backend/app/services/news_service.py b/backend/app/services/news_service.py
new file mode 100644
index 0000000..0f95096
--- /dev/null
+++ b/backend/app/services/news_service.py
@@ -0,0 +1,221 @@
+"""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"),
+ }