diff options
Diffstat (limited to 'backend/app')
| -rw-r--r-- | backend/app/main.py | 9 | ||||
| -rw-r--r-- | backend/app/schemas.py | 24 | ||||
| -rw-r--r-- | backend/app/services/news_service.py | 221 |
3 files changed, 252 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"), + } |
