You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

331 lines
12 KiB
Python

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