Files
openbb-invest-api/routes_sentiment.py
Yaojia Wang ec005c91a9
All checks were successful
continuous-integration/drone/push Build is passing
chore: fix all ruff lint warnings
- Remove unused datetime imports from openbb_service, market_service,
  quantitative_service (now using obb_utils.days_ago)
- Remove unused variable 'maintains' in routes_sentiment
- Remove unused imports in test files
- Fix forward reference annotation in test helper
2026-03-19 23:19:08 +01:00

224 lines
8.2 KiB
Python

"""Routes for sentiment, insider trades, and analyst data (Finnhub + Alpha Vantage)."""
import asyncio
from fastapi import APIRouter, Path, Query
from models import ApiResponse
from route_utils import safe, validate_symbol
import alphavantage_service
import finnhub_service
import openbb_service
import reddit_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
# --- Sentiment & News ---
@router.get("/stock/{symbol}/sentiment", response_model=ApiResponse)
@safe
async def stock_sentiment(symbol: str = Path(..., min_length=1, max_length=20)):
"""Aggregated sentiment from all sources with composite score.
Combines: Alpha Vantage news sentiment, Finnhub analyst data,
Reddit mentions, and analyst upgrades into a single composite score.
Score range: -1.0 (extreme bearish) to +1.0 (extreme bullish).
"""
symbol = validate_symbol(symbol)
# Fetch all sources in parallel
av_data, finnhub_data, reddit_data, upgrades_data, recs_data = await asyncio.gather(
alphavantage_service.get_news_sentiment(symbol, limit=20),
finnhub_service.get_sentiment_summary(symbol),
reddit_service.get_reddit_sentiment(symbol),
openbb_service.get_upgrades_downgrades(symbol, limit=10),
finnhub_service.get_recommendation_trends(symbol),
return_exceptions=True,
)
def _safe(result, default):
return default if isinstance(result, BaseException) else result
av_data = _safe(av_data, {})
finnhub_data = _safe(finnhub_data, {})
reddit_data = _safe(reddit_data, {})
upgrades_data = _safe(upgrades_data, [])
recs_data = _safe(recs_data, [])
# --- Score each source ---
scores: list[tuple[str, float, float]] = [] # (source, score, weight)
# 1. News sentiment (Alpha Vantage): avg_score ranges ~-0.35 to +0.35
if isinstance(av_data, dict) and av_data.get("overall_sentiment"):
av_score = av_data["overall_sentiment"].get("avg_score")
if av_score is not None:
# Normalize to -1..+1 (AV scores are typically -0.35 to +0.35)
normalized = max(-1.0, min(1.0, av_score * 2.5))
scores.append(("news", round(normalized, 3), 0.25))
# 2. Analyst recommendations (Finnhub): buy/sell ratio
if isinstance(recs_data, list) and recs_data:
latest = recs_data[0]
total = sum(latest.get(k, 0) for k in ("strongBuy", "buy", "hold", "sell", "strongSell"))
if total > 0:
bullish = latest.get("strongBuy", 0) + latest.get("buy", 0)
bearish = latest.get("sell", 0) + latest.get("strongSell", 0)
ratio = (bullish - bearish) / total # -1 to +1
scores.append(("analysts", round(ratio, 3), 0.30))
# 3. Analyst upgrades vs downgrades (yfinance)
if isinstance(upgrades_data, list) and upgrades_data:
ups = sum(1 for u in upgrades_data if u.get("action") in ("up", "init"))
downs = sum(1 for u in upgrades_data if u.get("action") == "down")
if len(upgrades_data) > 0:
upgrade_score = (ups - downs) / len(upgrades_data)
scores.append(("upgrades", round(upgrade_score, 3), 0.20))
# 4. Reddit buzz (ApeWisdom)
if isinstance(reddit_data, dict) and reddit_data.get("found"):
mentions = reddit_data.get("mentions_24h", 0)
change = reddit_data.get("mentions_change_pct")
if change is not None and mentions > 10:
# Positive change = bullish buzz, capped at +/- 1
reddit_score = max(-1.0, min(1.0, change / 100))
scores.append(("reddit", round(reddit_score, 3), 0.25))
# --- Compute weighted composite ---
if scores:
total_weight = sum(w for _, _, w in scores)
composite = sum(s * w for _, s, w in scores) / total_weight
composite = round(composite, 3)
else:
composite = None
# Map to label
if composite is None:
label = "Unknown"
elif composite >= 0.5:
label = "Strong Bullish"
elif composite >= 0.15:
label = "Bullish"
elif composite >= -0.15:
label = "Neutral"
elif composite >= -0.5:
label = "Bearish"
else:
label = "Strong Bearish"
return ApiResponse(data={
"symbol": symbol,
"composite_score": composite,
"composite_label": label,
"source_scores": {name: score for name, score, _ in scores},
"source_weights": {name: weight for name, _, weight in scores},
"details": {
"news_sentiment": av_data if isinstance(av_data, dict) else {},
"analyst_recommendations": recs_data[0] if isinstance(recs_data, list) and recs_data else {},
"recent_upgrades": upgrades_data[:5] if isinstance(upgrades_data, list) else [],
"reddit": reddit_data if isinstance(reddit_data, dict) else {},
"finnhub_news": (
finnhub_data.get("recent_news", [])[:5]
if isinstance(finnhub_data, dict)
else []
),
},
})
@router.get("/stock/{symbol}/news-sentiment", response_model=ApiResponse)
@safe
async def stock_news_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
limit: int = Query(default=30, ge=1, le=200),
):
"""Get news articles with per-ticker sentiment scores (Alpha Vantage)."""
symbol = validate_symbol(symbol)
data = await alphavantage_service.get_news_sentiment(symbol, limit=limit)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/insider-trades", response_model=ApiResponse)
@safe
async def stock_insider_trades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get insider transactions (CEO/CFO buys and sells)."""
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_insider_transactions(symbol)
trades = [
{
"name": t.get("name"),
"shares": t.get("share"),
"change": t.get("change"),
"transaction_date": t.get("transactionDate"),
"transaction_code": t.get("transactionCode"),
"transaction_price": t.get("transactionPrice"),
"filing_date": t.get("filingDate"),
}
for t in raw[:20]
]
return ApiResponse(data=trades)
@router.get("/stock/{symbol}/recommendations", response_model=ApiResponse)
@safe
async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get analyst recommendation trends (monthly buy/hold/sell counts)."""
symbol = validate_symbol(symbol)
raw = await finnhub_service.get_recommendation_trends(symbol)
recs = [
{
"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 raw[:12]
]
return ApiResponse(data=recs)
@router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
@safe
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)):
"""Get recent analyst upgrades and downgrades (via yfinance)."""
symbol = validate_symbol(symbol)
data = await openbb_service.get_upgrades_downgrades(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/social-sentiment", response_model=ApiResponse)
@safe
async def stock_social_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
):
"""Social media sentiment from Reddit and Twitter (Finnhub)."""
symbol = validate_symbol(symbol)
data = await finnhub_service.get_social_sentiment(symbol)
return ApiResponse(data=data)
@router.get("/stock/{symbol}/reddit-sentiment", response_model=ApiResponse)
@safe
async def stock_reddit_sentiment(
symbol: str = Path(..., min_length=1, max_length=20),
):
"""Reddit sentiment: mentions, upvotes, rank on WSB/stocks/investing (free, no key)."""
symbol = validate_symbol(symbol)
data = await reddit_service.get_reddit_sentiment(symbol)
return ApiResponse(data=data)
@router.get("/discover/reddit-trending", response_model=ApiResponse)
@safe
async def reddit_trending():
"""Top 25 trending stocks on Reddit (WSB, r/stocks, r/investing). Free, no key."""
data = await reddit_service.get_reddit_trending()
return ApiResponse(data=data)