""" News and Social Media API endpoints for ticker-based sentiment analysis """ from datetime import datetime from typing import Optional, Dict, Any, List import logging from fastapi import APIRouter, HTTPException, Query, Response from pydantic import BaseModel, Field from app.schemas.financial import NewsOnlyResponse, SocialOnlyResponse from app.services.news_social_service import news_social_service from app.utils.cache import with_cache logger = logging.getLogger(__name__) router = APIRouter() class NewsArticleSchema(BaseModel): """News article schema for API response""" title: str summary: Optional[str] = None content: Optional[str] = None url: str source: str published_at: Optional[str] = None author: Optional[str] = None relevance_score: Optional[float] = None image_url: Optional[str] = None tags: List[str] = [] class SocialPostSchema(BaseModel): """Social media post schema for API response""" title: str content: str url: str platform: str author: str published_at: Optional[str] = None score: Optional[int] = None comments_count: Optional[int] = None upvotes: Optional[int] = None downvotes: Optional[int] = None subreddit: Optional[str] = None class NewsSourcesSchema(BaseModel): """News sources breakdown""" yahoo_finance: int = 0 newsapi: int = 0 class SocialPlatformsSchema(BaseModel): """Social media platforms breakdown""" reddit: int = 0 class NewsSocialSummarySchema(BaseModel): """Summary of news and social data""" total_items: int time_range_days: int oldest_item: Optional[str] = None newest_item: Optional[str] = None class NewsSocialResponse(BaseModel): """Complete response for ticker news and social data""" ticker: str retrieved_at: str news: Dict[str, Any] = Field(description="News articles and sources breakdown") social_media: Dict[str, Any] = Field(description="Social media posts and platforms breakdown") summary: NewsSocialSummarySchema @router.get( "/{ticker}", response_model=NewsSocialResponse, summary="Get news and social media for a ticker", description=""" Fetch recent news articles and social media posts for a ticker from multiple sources. **News sources**: Yahoo Finance, NewsAPI **Social sources**: Reddit (r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/ValueInvesting) Both sources are fetched in parallel. Results are deduplicated and ranked by relevance. Cached for **10 minutes**. **Examples**: - `GET /news/AAPL` — last 7 days, up to 20 articles + 15 posts - `GET /news/TSLA?days_back=14&max_articles=50&include_social=false` — news-only, 2 weeks """, ) @with_cache(namespace="news:full", ttl=600, key_params=["ticker", "days_back", "max_articles", "max_social_posts", "include_social"]) async def get_ticker_news_and_social( ticker: str, response: Response, days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for articles (1-30)"), max_articles: int = Query(20, ge=1, le=100, description="Maximum number of news articles to return (1-100)"), max_social_posts: int = Query(15, ge=0, le=50, description="Maximum number of social media posts to return (0-50)"), include_social: bool = Query(True, description="Whether to include social media data"), force_refresh: bool = Query(False, description="Bypass cache and fetch fresh data"), ): """ Get comprehensive news and social media data for a ticker - **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ) - **days_back**: Number of days to look back for articles (default: 7, max: 30) - **max_articles**: Maximum number of news articles to return (default: 20, min: 1, max: 100) - **max_social_posts**: Maximum number of social media posts to return (default: 15, max: 50) - **include_social**: Whether to include social media data (default: true) ## Data Sources - **News**: Yahoo Finance, NewsAPI - **Social Media**: Reddit (multiple investing subreddits) ## Features - ✅ Parallel data fetching from multiple sources - ✅ Automatic deduplication and relevance ranking - ✅ Rate limiting and error handling - ✅ Comprehensive metadata and source attribution ## Use Cases - Sentiment analysis and market research - News aggregation for trading decisions - Social media monitoring for retail sentiment - Research and fundamental analysis support """ try: # Validate ticker format ticker_upper = ticker.upper().strip() if not ticker_upper or len(ticker_upper) > 10: raise HTTPException( status_code=400, detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters." ) logger.info(f"Fetching news and social data for {ticker_upper}") # Get data from service result = await news_social_service.get_ticker_news_and_social( ticker=ticker_upper, days_back=days_back, max_articles=max_articles, max_social_posts=max_social_posts, include_social=include_social ) logger.info(f"Successfully retrieved {result['news']['total_articles']} articles and {result['social_media']['total_posts']} social posts for {ticker_upper}") return result except ValueError as e: logger.error(f"Invalid input for {ticker}: {e}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error fetching news and social data for {ticker}: {e}") raise HTTPException( status_code=500, detail=f"Failed to retrieve news and social data for {ticker}. Please try again later." ) @router.get( "/{ticker}/news-only", response_model=NewsOnlyResponse, summary="Get news articles for a ticker (no social media)", description=""" Faster endpoint that returns only news articles, skipping social media API calls. **Sources**: Yahoo Finance, NewsAPI Cached for **10 minutes**. **Example**: `GET /news/NVDA/news-only?days_back=3&max_articles=30` """, ) @with_cache(namespace="news:news-only", ttl=600, key_params=["ticker", "days_back", "max_articles"]) async def get_ticker_news_only( ticker: str, response: Response, days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for articles (1-30)"), max_articles: int = Query(30, ge=1, le=100, description="Maximum number of news articles to return (1-100)"), force_refresh: bool = Query(False, description="Bypass cache and fetch fresh data"), ): """ Get only news articles for a ticker (faster endpoint without social media data) - **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ) - **days_back**: Number of days to look back for articles (default: 7, max: 30) - **max_articles**: Maximum number of news articles to return (default: 30, min: 1, max: 100) ## Performance - ⚡ Faster response time (no social media API calls) - ⚡ Optimized for high-frequency news monitoring - ⚡ Ideal for news-only sentiment analysis """ try: ticker_upper = ticker.upper().strip() if not ticker_upper or len(ticker_upper) > 10: raise HTTPException( status_code=400, detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters." ) logger.info(f"Fetching news-only data for {ticker_upper}") # Get data with social media disabled result = await news_social_service.get_ticker_news_and_social( ticker=ticker_upper, days_back=days_back, max_articles=max_articles, max_social_posts=0, include_social=False ) # Return only news portion news_only_result = { "ticker": result["ticker"], "retrieved_at": result["retrieved_at"], "news": result["news"], "summary": { "total_articles": result["news"]["total_articles"], "time_range_days": days_back, "sources": result["news"]["sources"] } } logger.info(f"Successfully retrieved {result['news']['total_articles']} articles for {ticker_upper}") return news_only_result except ValueError as e: logger.error(f"Invalid input for {ticker}: {e}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error fetching news for {ticker}: {e}") raise HTTPException( status_code=500, detail=f"Failed to retrieve news for {ticker}. Please try again later." ) @router.get( "/{ticker}/social-only", response_model=SocialOnlyResponse, summary="Get social media posts for a ticker", description=""" Returns only Reddit posts for a ticker, skipping news API calls. **Subreddits**: r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/StockMarket, r/ValueInvesting, r/financialindependence Cached for **10 minutes**. **Example**: `GET /news/GME/social-only?days_back=3&max_social_posts=30` """, ) @with_cache(namespace="news:social-only", ttl=600, key_params=["ticker", "days_back", "max_social_posts"]) async def get_ticker_social_only( ticker: str, response: Response, days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for posts (1-30)"), max_social_posts: int = Query(20, ge=1, le=50, description="Maximum number of social media posts to return (1-50)"), force_refresh: bool = Query(False, description="Bypass cache and fetch fresh data"), ): """ Get only social media posts for a ticker - **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ) - **days_back**: Number of days to look back for posts (default: 7, max: 30) - **max_social_posts**: Maximum number of social media posts to return (default: 20, min: 1, max: 50) ## Social Media Sources - Reddit: r/stocks, r/investing, r/SecurityAnalysis, r/StockMarket, r/ValueInvesting, r/financialindependence, r/wallstreetbets ## Use Cases - Retail investor sentiment monitoring - Social media trend analysis - Community discussion tracking """ try: ticker_upper = ticker.upper().strip() if not ticker_upper or len(ticker_upper) > 10: raise HTTPException( status_code=400, detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters." ) logger.info(f"Fetching social-only data for {ticker_upper}") # Get data with minimal news articles result = await news_social_service.get_ticker_news_and_social( ticker=ticker_upper, days_back=days_back, max_articles=0, # Minimal news data max_social_posts=max_social_posts, include_social=True ) # Return only social media portion social_only_result = { "ticker": result["ticker"], "retrieved_at": result["retrieved_at"], "social_media": result["social_media"], "summary": { "total_posts": result["social_media"]["total_posts"], "time_range_days": days_back, "platforms": result["social_media"]["platforms"] } } logger.info(f"Successfully retrieved {result['social_media']['total_posts']} social posts for {ticker_upper}") return social_only_result except ValueError as e: logger.error(f"Invalid input for {ticker}: {e}") raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error fetching social media data for {ticker}: {e}") raise HTTPException( status_code=500, detail=f"Failed to retrieve social media data for {ticker}. Please try again later." )