summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:45 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:45 -0700
commit911765d8d1094df1baed4c3a313988e18cc31755 (patch)
treee209289341237a9769e84158df36cc16937bdcae
parent570b2ec7c5890ef6f11eab5ac1c56eb3ec90911f (diff)
feat: add backend news service with Finnhub/FMP fallback and sentiment
-rw-r--r--backend/app/main.py9
-rw-r--r--backend/app/schemas.py24
-rw-r--r--backend/app/services/news_service.py221
-rw-r--r--backend/tests/test_news_service.py200
4 files changed, 452 insertions, 2 deletions
diff --git a/backend/app/main.py b/backend/app/main.py
index 71ddd7a..e7b9691 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,8 +9,8 @@ from fastapi import FastAPI, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
from app.db import watchlist
-from app.schemas import FilingsResponse, FinancialsResponse, HistoryPoint, InsidersResponse, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse
-from app.services import data_service
+from app.schemas import FilingsResponse, FinancialsResponse, HistoryPoint, InsidersResponse, MarketIndex, NewsResponse, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse
+from app.services import data_service, news_service
load_dotenv()
@@ -90,6 +90,11 @@ def ticker_filings(symbol: str) -> dict:
return data_service.get_sec_filings(symbol)
+@app.get("/api/tickers/{symbol}/news", response_model=NewsResponse)
+def ticker_news(symbol: str) -> dict:
+ return news_service.get_news(symbol)
+
+
@app.get("/api/watchlist", response_model=WatchlistResponse)
def get_watchlist() -> dict:
items = []
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 86664e4..351de06 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -273,3 +273,27 @@ class FilingsResponse(BaseModel):
class ErrorResponse(BaseModel):
detail: str
+
+
+class NewsItem(BaseModel):
+ id: str
+ title: str
+ summary: str
+ source: str
+ url: str
+ sentiment: Literal["bullish", "neutral", "bearish"]
+ published_at: str
+
+
+class NewsAggregate(BaseModel):
+ buzz_articles_last_week: int
+ bullish_pct: float
+ neutral_pct: float
+ bearish_pct: float
+
+
+class NewsResponse(BaseModel):
+ items: list[NewsItem] = Field(default_factory=list)
+ aggregate: NewsAggregate | None = None
+ provider: Literal["finnhub", "fmp"] | None = None
+ fetched_at: str
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"),
+ }
diff --git a/backend/tests/test_news_service.py b/backend/tests/test_news_service.py
new file mode 100644
index 0000000..297c2f6
--- /dev/null
+++ b/backend/tests/test_news_service.py
@@ -0,0 +1,200 @@
+"""Tests for backend news service."""
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.services import news_service
+
+
+def _clear_news_cache() -> None:
+ news_service.NEWS_CACHE.clear()
+
+
+def _finnhub_article(**kwargs) -> dict:
+ defaults = {
+ "datetime": int(datetime(2026, 6, 22, 18, 42, 0, tzinfo=timezone.utc).timestamp()),
+ "headline": "AMD surges on strong demand",
+ "summary": "Chipmaker gains as data-center revenue rises",
+ "source": "bloomberg.com",
+ "url": "https://www.bloomberg.com/news/article",
+ }
+ defaults.update(kwargs)
+ return defaults
+
+
+def _fmp_article(**kwargs) -> dict:
+ defaults = {
+ "publishedDate": "2026-06-22 18:42:00",
+ "title": "AMD misses on guidance",
+ "text": "Semiconductor firm issues weak outlook",
+ "site": "barrons.com",
+ "newsURL": "https://www.barrons.com/articles/amd",
+ }
+ defaults.update(kwargs)
+ return defaults
+
+
+def test_get_news_shape(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article()])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: {
+ "buzz": {"articlesInLastWeek": 12},
+ "sentiment": {"bullishPercent": 0.38, "bearishPercent": 0.40, "neutralPercent": 0.22},
+ })
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["provider"] == "finnhub"
+ assert len(result["items"]) == 1
+ item = result["items"][0]
+ assert item["id"] == "6aa272d2b9f4f7f4"
+ assert item["title"] == "AMD surges on strong demand"
+ assert item["summary"] == "Chipmaker gains as data-center revenue rises"
+ assert item["source"] == "bloomberg"
+ assert item["url"] == "https://www.bloomberg.com/news/article"
+ assert item["sentiment"] == "bullish"
+ assert item["published_at"] == "2026-06-22T18:42:00Z"
+ assert result["aggregate"] == {
+ "buzz_articles_last_week": 12,
+ "bullish_pct": 0.38,
+ "bearish_pct": 0.40,
+ "neutral_pct": 0.22,
+ }
+ assert result["fetched_at"]
+
+
+def test_get_news_fmp_fallback_when_finnhub_empty(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [_fmp_article()])
+
+ result = news_service.get_news("AMD")
+
+ assert result["provider"] == "fmp"
+ assert len(result["items"]) == 1
+ item = result["items"][0]
+ assert item["title"] == "AMD misses on guidance"
+ assert item["summary"] == "Semiconductor firm issues weak outlook"
+ assert item["source"] == "barrons"
+ assert item["url"] == "https://www.barrons.com/articles/amd"
+ assert item["sentiment"] == "bearish"
+ assert item["published_at"] == "2026-06-22T18:42:00Z"
+ assert result["aggregate"] is None
+
+
+def test_get_news_drops_unsafe_urls(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(url="javascript:alert(1)"),
+ _finnhub_article(url="data:text/html,<script>alert(1)</script>"),
+ _finnhub_article(url="https://safe.example.com/news"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 1
+ assert result["items"][0]["url"] == "https://safe.example.com/news"
+
+
+def test_get_news_both_upstream_empty(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"] == []
+ assert result["aggregate"] is None
+ assert result["provider"] is None
+ assert result["fetched_at"]
+
+
+def test_get_news_aggregate_failure_still_returns_items(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article()])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 1
+ assert result["aggregate"] is None
+
+
+def test_get_news_sentiment_classifications(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(headline="AMD surges on strong demand", summary="growth beats"),
+ _finnhub_article(headline="AMD misses and drops", summary="weak warning"),
+ _finnhub_article(headline="AMD reports quarterly results", summary="revenue flat"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+ sentiments = [i["sentiment"] for i in result["items"]]
+
+ assert "bullish" in sentiments
+ assert "bearish" in sentiments
+ assert "neutral" in sentiments
+
+
+def test_get_news_caches_result(monkeypatch) -> None:
+ _clear_news_cache()
+ call_count = {"finnhub": 0}
+
+ def _finnhub(symbol: str) -> list[dict]:
+ call_count["finnhub"] += 1
+ return [_finnhub_article()]
+
+ monkeypatch.setattr(news_service, "_finnhub_company_news", _finnhub)
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ news_service.get_news("AMD")
+ news_service.get_news("AMD")
+
+ assert call_count["finnhub"] == 1
+
+
+def test_get_news_date_parsing_variants(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [
+ _fmp_article(publishedDate="2026-06-21 09:15:30"),
+ ])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"][0]["published_at"] == "2026-06-21T09:15:30Z"
+
+
+def test_get_news_trims_and_cleans_source(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(source=" WWW.REUTERS.COM ", url="https://www.reuters.com/x"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"][0]["source"] == "reuters"
+
+
+def test_get_news_capped_at_100(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article(headline=f"Article {i}") for i in range(150)])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 100