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.
289 lines
11 KiB
Python
289 lines
11 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)
|
|
@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)
|
|
@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)
|
|
@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."
|
|
) |