"""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)