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