feat: OpenBB Investment Analysis API
REST API wrapping OpenBB SDK for stock data, sentiment analysis, technical indicators, macro data, and rule-based portfolio analysis. - Stock data via yfinance (quote, profile, metrics, financials, historical, news) - News sentiment via Alpha Vantage (per-article, per-ticker scores) - Analyst data via Finnhub (recommendations, insider trades, upgrades) - Macro data via FRED (Fed rate, CPI, GDP, unemployment, treasury yields) - Technical indicators via openbb-technical (RSI, MACD, SMA, EMA, Bollinger) - Rule-based portfolio analysis engine (BUY_MORE/HOLD/SELL) - Stock discovery (gainers, losers, active, undervalued, growth) - 102 tests, all passing
This commit is contained in:
158
alphavantage_service.py
Normal file
158
alphavantage_service.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Alpha Vantage API client for news sentiment analysis."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://www.alphavantage.co/query"
|
||||
TIMEOUT = 15.0
|
||||
|
||||
|
||||
def _is_configured() -> bool:
|
||||
return bool(settings.alphavantage_api_key)
|
||||
|
||||
|
||||
async def get_news_sentiment(
|
||||
symbol: str, limit: int = 50
|
||||
) -> dict[str, Any]:
|
||||
"""Get news articles with per-ticker sentiment scores.
|
||||
|
||||
Returns articles with sentiment_score (-1 to 1),
|
||||
sentiment_label (Bearish/Bullish/Neutral), and relevance_score.
|
||||
Free tier: 25 requests/day.
|
||||
"""
|
||||
if not _is_configured():
|
||||
return {
|
||||
"configured": False,
|
||||
"message": "Set INVEST_API_ALPHAVANTAGE_API_KEY to enable news sentiment",
|
||||
}
|
||||
|
||||
params = {
|
||||
"function": "NEWS_SENTIMENT",
|
||||
"tickers": symbol,
|
||||
"limit": limit,
|
||||
"apikey": settings.alphavantage_api_key,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(BASE_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if "Error Message" in data or "Note" in data:
|
||||
msg = data.get("Error Message") or data.get("Note", "")
|
||||
logger.warning("Alpha Vantage error: %s", msg)
|
||||
return {"configured": True, "error": msg, "articles": []}
|
||||
|
||||
feed = data.get("feed", [])
|
||||
articles = [_parse_article(article, symbol) for article in feed]
|
||||
overall = _compute_overall_sentiment(articles)
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"symbol": symbol,
|
||||
"article_count": len(articles),
|
||||
"overall_sentiment": overall,
|
||||
"articles": articles,
|
||||
}
|
||||
|
||||
|
||||
def _parse_article(article: dict[str, Any], symbol: str) -> dict[str, Any]:
|
||||
"""Extract relevant fields from a single news article."""
|
||||
ticker_sentiment = _find_ticker_sentiment(
|
||||
article.get("ticker_sentiment", []), symbol
|
||||
)
|
||||
|
||||
return {
|
||||
"title": article.get("title"),
|
||||
"url": article.get("url"),
|
||||
"source": article.get("source"),
|
||||
"published": article.get("time_published"),
|
||||
"summary": article.get("summary"),
|
||||
"overall_sentiment_score": _safe_float(
|
||||
article.get("overall_sentiment_score")
|
||||
),
|
||||
"overall_sentiment_label": article.get("overall_sentiment_label"),
|
||||
"ticker_sentiment_score": ticker_sentiment.get("score"),
|
||||
"ticker_sentiment_label": ticker_sentiment.get("label"),
|
||||
"ticker_relevance": ticker_sentiment.get("relevance"),
|
||||
"topics": [
|
||||
t.get("topic") for t in article.get("topics", [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _find_ticker_sentiment(
|
||||
sentiments: list[dict[str, Any]], symbol: str
|
||||
) -> dict[str, Any]:
|
||||
"""Find the sentiment entry matching the requested ticker."""
|
||||
upper = symbol.upper()
|
||||
for s in sentiments:
|
||||
ticker = s.get("ticker", "")
|
||||
if ticker.upper() == upper:
|
||||
return {
|
||||
"score": _safe_float(s.get("ticker_sentiment_score")),
|
||||
"label": s.get("ticker_sentiment_label"),
|
||||
"relevance": _safe_float(s.get("relevance_score")),
|
||||
}
|
||||
return {"score": None, "label": None, "relevance": None}
|
||||
|
||||
|
||||
def _safe_float(val: Any) -> float | None:
|
||||
"""Convert value to float, returning None on failure."""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _compute_overall_sentiment(
|
||||
articles: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Compute aggregate sentiment stats from parsed articles."""
|
||||
scores = [
|
||||
a["ticker_sentiment_score"]
|
||||
for a in articles
|
||||
if a["ticker_sentiment_score"] is not None
|
||||
]
|
||||
if not scores:
|
||||
return {
|
||||
"avg_score": None,
|
||||
"label": "Unknown",
|
||||
"bullish_count": 0,
|
||||
"bearish_count": 0,
|
||||
"neutral_count": 0,
|
||||
"total_scored": 0,
|
||||
}
|
||||
|
||||
avg = sum(scores) / len(scores)
|
||||
bullish = sum(1 for s in scores if s >= 0.15)
|
||||
bearish = sum(1 for s in scores if s <= -0.15)
|
||||
neutral = len(scores) - bullish - bearish
|
||||
|
||||
if avg >= 0.35:
|
||||
label = "Bullish"
|
||||
elif avg >= 0.15:
|
||||
label = "Somewhat-Bullish"
|
||||
elif avg >= -0.15:
|
||||
label = "Neutral"
|
||||
elif avg >= -0.35:
|
||||
label = "Somewhat-Bearish"
|
||||
else:
|
||||
label = "Bearish"
|
||||
|
||||
return {
|
||||
"avg_score": round(avg, 4),
|
||||
"label": label,
|
||||
"bullish_count": bullish,
|
||||
"bearish_count": bearish,
|
||||
"neutral_count": neutral,
|
||||
"total_scored": len(scores),
|
||||
}
|
||||
Reference in New Issue
Block a user