Files
openbb-invest-api/finnhub_service.py
Yaojia Wang ad45cb429c 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
2026-03-09 00:20:10 +01:00

176 lines
6.0 KiB
Python

"""Finnhub API client for sentiment, insider trades, and analyst data."""
import logging
from datetime import datetime, timedelta
from typing import Any
import httpx
from config import settings
logger = logging.getLogger(__name__)
BASE_URL = "https://finnhub.io/api/v1"
TIMEOUT = 15.0
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=BASE_URL,
timeout=TIMEOUT,
params={"token": settings.finnhub_api_key},
)
def _is_configured() -> bool:
return bool(settings.finnhub_api_key)
async def get_news_sentiment(symbol: str) -> dict[str, Any]:
"""Get aggregated news sentiment scores for a symbol.
Note: This endpoint requires a Finnhub premium plan.
Returns empty dict on 403 (free tier).
"""
if not _is_configured():
return {}
async with _client() as client:
resp = await client.get("/news-sentiment", params={"symbol": symbol})
if resp.status_code == 403:
logger.debug("news-sentiment endpoint requires premium plan, skipping")
return {}
resp.raise_for_status()
return resp.json()
async def get_company_news(symbol: str, days: int = 7) -> list[dict[str, Any]]:
"""Get recent company news articles."""
if not _is_configured():
return []
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
async with _client() as client:
resp = await client.get(
"/company-news",
params={"symbol": symbol, "from": start, "to": end},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else []
async def get_insider_transactions(symbol: str) -> list[dict[str, Any]]:
"""Get insider transactions for a symbol."""
if not _is_configured():
return []
async with _client() as client:
resp = await client.get(
"/stock/insider-transactions",
params={"symbol": symbol},
)
resp.raise_for_status()
data = resp.json()
return data.get("data", []) if isinstance(data, dict) else []
async def get_recommendation_trends(symbol: str) -> list[dict[str, Any]]:
"""Get analyst recommendation trends (monthly breakdown)."""
if not _is_configured():
return []
async with _client() as client:
resp = await client.get(
"/stock/recommendation",
params={"symbol": symbol},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else []
async def get_upgrade_downgrade(
symbol: str, days: int = 90
) -> list[dict[str, Any]]:
"""Get recent analyst upgrades/downgrades."""
if not _is_configured():
return []
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
async with _client() as client:
resp = await client.get(
"/stock/upgrade-downgrade",
params={"symbol": symbol, "from": start},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else []
async def get_sentiment_summary(symbol: str) -> dict[str, Any]:
"""Aggregate all sentiment data for a symbol into one response."""
if not _is_configured():
return {"configured": False, "message": "Set INVEST_API_FINNHUB_API_KEY to enable sentiment data"}
import asyncio
news_sentiment, company_news, recommendations, upgrades = await asyncio.gather(
get_news_sentiment(symbol),
get_company_news(symbol, days=7),
get_recommendation_trends(symbol),
get_upgrade_downgrade(symbol, days=90),
return_exceptions=True,
)
def _safe_result(result: Any, default: Any) -> Any:
return default if isinstance(result, BaseException) else result
news_sentiment = _safe_result(news_sentiment, {})
company_news = _safe_result(company_news, [])
recommendations = _safe_result(recommendations, [])
upgrades = _safe_result(upgrades, [])
# Extract key sentiment metrics
sentiment_data = news_sentiment.get("sentiment", {}) if isinstance(news_sentiment, dict) else {}
buzz_data = news_sentiment.get("buzz", {}) if isinstance(news_sentiment, dict) else {}
return {
"symbol": symbol,
"news_sentiment": {
"bullish_percent": sentiment_data.get("bullishPercent"),
"bearish_percent": sentiment_data.get("bearishPercent"),
"news_score": news_sentiment.get("companyNewsScore") if isinstance(news_sentiment, dict) else None,
"sector_avg_score": news_sentiment.get("sectorAverageNewsScore") if isinstance(news_sentiment, dict) else None,
"articles_last_week": buzz_data.get("articlesInLastWeek"),
"weekly_average": buzz_data.get("weeklyAverage"),
"buzz": buzz_data.get("buzz"),
},
"recent_news": [
{
"headline": n.get("headline"),
"source": n.get("source"),
"url": n.get("url"),
"datetime": n.get("datetime"),
"summary": n.get("summary"),
}
for n in (company_news[:10] if isinstance(company_news, list) else [])
],
"analyst_recommendations": [
{
"period": r.get("period"),
"strong_buy": r.get("strongBuy"),
"buy": r.get("buy"),
"hold": r.get("hold"),
"sell": r.get("sell"),
"strong_sell": r.get("strongSell"),
}
for r in (recommendations[:6] if isinstance(recommendations, list) else [])
],
"recent_upgrades_downgrades": [
{
"company": u.get("company"),
"action": u.get("action"),
"from_grade": u.get("fromGrade"),
"to_grade": u.get("toGrade"),
"date": u.get("gradeTime"),
}
for u in (upgrades[:10] if isinstance(upgrades, list) else [])
],
}