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:
Yaojia Wang
2026-03-09 00:20:10 +01:00
commit ad45cb429c
30 changed files with 3107 additions and 0 deletions

175
finnhub_service.py Normal file
View File

@@ -0,0 +1,175 @@
"""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 [])
],
}