| """ |
| sentiment_service.py |
| ~~~~~~~~~~~~~~~~~~~~ |
| Robust news sentiment for any ticker using a tiered approach: |
| |
| Tier 1 β NewsAPI (fast, broad coverage) |
| Tier 2 β Yahoo Finance RSS (free, no key needed, good for US stocks) |
| Tier 3 β Finnhub (great for less-covered stocks, needs free API key) |
| |
| Each headline is scored with FinBERT. Returns a full response matching |
| the SentimentPanel frontend component. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import logging |
| import urllib.request |
| import urllib.parse |
| import json |
| import xml.etree.ElementTree as ET |
| from functools import lru_cache |
| from typing import List |
|
|
| import numpy as np |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| @lru_cache(maxsize=1) |
| def _get_finbert(): |
| from transformers import pipeline |
| return pipeline( |
| "text-classification", |
| model="ProsusAI/finbert", |
| tokenizer="ProsusAI/finbert", |
| device=-1, |
| top_k=None, |
| truncation=True, |
| max_length=512, |
| ) |
|
|
| LABEL_MAP = {"positive": 1.0, "negative": -1.0, "neutral": 0.0} |
|
|
|
|
| |
| def _score_one(text: str): |
| pipe = _get_finbert() |
| result = pipe(text[:512])[0] |
| weighted = sum(LABEL_MAP[r["label"]] * r["score"] for r in result) |
| top = max(result, key=lambda r: r["score"]) |
| return weighted, top["label"], round(top["score"], 4) |
|
|
|
|
| |
| def _fetch_newsapi(ticker: str, company_name: str = "") -> List[dict]: |
| api_key = os.getenv("NEWSAPI_KEY", "") |
| if not api_key: |
| return [] |
| query = urllib.parse.quote(f"{ticker} OR {company_name}" if company_name else ticker) |
| url = ( |
| f"https://newsapi.org/v2/everything" |
| f"?q={query}&language=en&sortBy=publishedAt&pageSize=10" |
| f"&apiKey={api_key}" |
| ) |
| try: |
| with urllib.request.urlopen(url, timeout=5) as resp: |
| data = json.loads(resp.read()) |
| return [ |
| { |
| "title": a.get("title", ""), |
| "url": a.get("url", ""), |
| "source": a.get("source", {}).get("name", "NewsAPI"), |
| "published_at": a.get("publishedAt", ""), |
| } |
| for a in data.get("articles", []) if a.get("title") |
| ] |
| except Exception as e: |
| logger.warning("NewsAPI failed for %s: %s", ticker, e) |
| return [] |
|
|
|
|
| def _fetch_yahoo_rss(ticker: str) -> List[dict]: |
| url = f"https://feeds.finance.yahoo.com/rss/2.0/headline?s={ticker}®ion=US&lang=en-US" |
| try: |
| req = urllib.request.Request(url, headers={"User-Agent": "AlphaSignal/1.0"}) |
| with urllib.request.urlopen(req, timeout=5) as resp: |
| root = ET.fromstring(resp.read()) |
| results = [] |
| for item in root.findall(".//item")[:15]: |
| title = item.findtext("title") or "" |
| if title: |
| results.append({ |
| "title": title, |
| "url": item.findtext("link") or "", |
| "source": "Yahoo Finance", |
| "published_at": item.findtext("pubDate") or "", |
| }) |
| return results |
| except Exception as e: |
| logger.warning("Yahoo RSS failed for %s: %s", ticker, e) |
| return [] |
|
|
|
|
| def _fetch_finnhub(ticker: str) -> List[dict]: |
| api_key = os.getenv("FINNHUB_KEY", "") |
| if not api_key: |
| return [] |
| from datetime import date, timedelta |
| today = date.today().strftime("%Y-%m-%d") |
| week = (date.today() - timedelta(days=7)).strftime("%Y-%m-%d") |
| url = ( |
| f"https://finnhub.io/api/v1/company-news" |
| f"?symbol={ticker}&from={week}&to={today}&token={api_key}" |
| ) |
| try: |
| with urllib.request.urlopen(url, timeout=5) as resp: |
| return [ |
| { |
| "title": a.get("headline", ""), |
| "url": a.get("url", ""), |
| "source": a.get("source", "Finnhub"), |
| "published_at": str(a.get("datetime", "")), |
| } |
| for a in json.loads(resp.read())[:15] if a.get("headline") |
| ] |
| except Exception as e: |
| logger.warning("Finnhub failed for %s: %s", ticker, e) |
| return [] |
|
|
|
|
| def _dedupe(articles: List[dict]) -> List[dict]: |
| seen, out = set(), [] |
| for a in articles: |
| key = a["title"].lower().strip() |
| if key not in seen: |
| seen.add(key) |
| out.append(a) |
| return out |
|
|
|
|
| |
| def _neutral_response(ticker: str, message: str = "No relevant news found.") -> dict: |
| return { |
| "ticker": ticker, |
| "headline_count": 0, |
| "overall_sentiment": "neutral", |
| "positive_pct": 0, |
| "neutral_pct": 100, |
| "negative_pct": 0, |
| "score": 0.0, |
| "source": "none", |
| "message": message, |
| "headlines": [], |
| } |
|
|
|
|
| |
| def get_sentiment_score(ticker: str, company_name: str = "") -> dict: |
| """ |
| Returns a dict matching SentimentPanel.jsx: |
| { |
| ticker, headline_count, overall_sentiment, |
| positive_pct, neutral_pct, negative_pct, |
| score, source, |
| headlines: [{ title, url, source, published_at, sentiment, score }] |
| } |
| """ |
| articles: List[dict] = [] |
| articles.extend(_fetch_newsapi(ticker, company_name)) |
| articles.extend(_fetch_yahoo_rss(ticker)) |
| if len(articles) < 5: |
| articles.extend(_fetch_finnhub(ticker)) |
|
|
| articles = _dedupe(articles) |
|
|
| if len(articles) < 3: |
| return _neutral_response(ticker) |
|
|
| |
| scored = [] |
| for a in articles: |
| try: |
| weighted, label, confidence = _score_one(a["title"]) |
| scored.append({ |
| "title": a["title"], |
| "url": a["url"], |
| "source": a["source"], |
| "published_at": a["published_at"], |
| "sentiment": label, |
| "score": confidence, |
| }) |
| except Exception as e: |
| logger.warning("Scoring failed: %s", e) |
|
|
| if not scored: |
| return _neutral_response(ticker, "Sentiment scoring failed.") |
|
|
| |
| n = len(scored) |
| pos_count = sum(1 for s in scored if s["sentiment"] == "positive") |
| neu_count = sum(1 for s in scored if s["sentiment"] == "neutral") |
| neg_count = sum(1 for s in scored if s["sentiment"] == "negative") |
| positive_pct = round(pos_count / n * 100) |
| neutral_pct = round(neu_count / n * 100) |
| negative_pct = round(neg_count / n * 100) |
|
|
| overall_score = float(np.mean([LABEL_MAP[s["sentiment"]] * s["score"] for s in scored])) |
|
|
| if overall_score >= 0.15: |
| overall_sentiment = "positive" |
| elif overall_score <= -0.15: |
| overall_sentiment = "negative" |
| else: |
| overall_sentiment = "neutral" |
|
|
| return { |
| "ticker": ticker, |
| "headline_count": n, |
| "overall_sentiment": overall_sentiment, |
| "positive_pct": positive_pct, |
| "neutral_pct": neutral_pct, |
| "negative_pct": negative_pct, |
| "score": round(overall_score, 4), |
| "source": "+".join(sorted({a["source"] for a in articles})), |
| "headlines": scored, |
| } |