Redesign /stock/{symbol}/sentiment to combine 4 data sources with
weighted scoring:
- News sentiment (Alpha Vantage, 25%) - article-level bullish/bearish
- Analyst recommendations (Finnhub, 30%) - buy/sell ratio
- Upgrade/downgrade activity (yfinance, 20%) - recent actions
- Reddit buzz (ApeWisdom, 25%) - mention change trend
Returns composite_score (-1 to +1), composite_label, per-source
scores, and full detail data from each source.
224 lines
8.2 KiB
Python
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 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),
|
|
finnhub_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")
|
|
maintains = len(upgrades_data) - ups - downs
|
|
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 finnhub_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 finnhub_service.get_reddit_trending()
|
|
return ApiResponse(data=data)
|