406 lines
14 KiB
Python
406 lines
14 KiB
Python
"""
|
|
Stock News Agent - 美股新闻资讯 Agent
|
|
获取美股相关新闻、市场动态和公司公告
|
|
"""
|
|
import os
|
|
import sys
|
|
import logging
|
|
import aiohttp
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
import uvicorn
|
|
|
|
# 添加 common 模块路径
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
# 导入回调工具
|
|
try:
|
|
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
|
CALLBACK_ENABLED = True
|
|
except ImportError:
|
|
CALLBACK_ENABLED = False
|
|
AgentCallbackHandler = None
|
|
CallbackContextManager = None
|
|
|
|
# 配置日志
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 环境变量
|
|
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
|
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8080"))
|
|
POD_NAME = os.getenv("POD_NAME", "stock-news-agent")
|
|
USER_ID = os.getenv("USER_ID", "")
|
|
|
|
# News API (可选)
|
|
NEWS_API_KEY = os.getenv("NEWS_API_KEY", "")
|
|
|
|
# FastAPI 应用
|
|
app = FastAPI(
|
|
title="Stock News Agent",
|
|
description="美股新闻资讯 - 获取股票相关新闻、市场动态和分析报告",
|
|
version="1.0.0"
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# 回调处理器
|
|
callback_handler: Optional[AgentCallbackHandler] = None
|
|
|
|
|
|
# ==================== 请求/响应模型 ====================
|
|
|
|
class NewsItem(BaseModel):
|
|
"""新闻条目"""
|
|
title: str
|
|
description: Optional[str] = None
|
|
url: str
|
|
source: str
|
|
published_at: str
|
|
sentiment: Optional[str] = None # positive, negative, neutral
|
|
|
|
|
|
class NewsRequest(BaseModel):
|
|
"""新闻查询请求"""
|
|
symbol: Optional[str] = Field(None, description="股票代码,如 AAPL")
|
|
query: Optional[str] = Field(None, description="搜索关键词")
|
|
limit: int = Field(10, ge=1, le=50, description="返回新闻数量")
|
|
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
|
|
|
|
|
|
class NewsResponse(BaseModel):
|
|
"""新闻查询响应"""
|
|
symbol: Optional[str] = None
|
|
query: Optional[str] = None
|
|
news: List[NewsItem]
|
|
total_count: int
|
|
timestamp: str
|
|
|
|
|
|
class MarketSummaryResponse(BaseModel):
|
|
"""市场概要响应"""
|
|
market_status: str
|
|
top_gainers: List[Dict[str, Any]]
|
|
top_losers: List[Dict[str, Any]]
|
|
most_active: List[Dict[str, Any]]
|
|
timestamp: str
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
"""健康检查响应"""
|
|
status: str
|
|
pod_name: str
|
|
news_api_configured: bool
|
|
callback_enabled: bool
|
|
timestamp: str
|
|
|
|
|
|
# ==================== 生命周期 ====================
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""应用启动时初始化回调处理器"""
|
|
global callback_handler
|
|
|
|
if CALLBACK_ENABLED:
|
|
callback_handler = AgentCallbackHandler(
|
|
agent_name=POD_NAME,
|
|
user_id=USER_ID
|
|
)
|
|
logger.info(f"回调处理器已初始化: callback_url={callback_handler.callback_url}")
|
|
else:
|
|
logger.warning("回调模块未加载,计费回调功能不可用")
|
|
|
|
|
|
# ==================== 辅助函数 ====================
|
|
|
|
async def fetch_yahoo_news(symbol: str = None, query: str = None, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""从 Yahoo Finance 获取新闻"""
|
|
news_list = []
|
|
|
|
# 构建搜索词
|
|
search_term = symbol if symbol else (query if query else "stock market")
|
|
|
|
# Yahoo Finance RSS 新闻源
|
|
url = f"https://query1.finance.yahoo.com/v1/finance/search"
|
|
params = {
|
|
"q": search_term,
|
|
"newsCount": limit,
|
|
"enableFuzzyQuery": False,
|
|
"quotesQueryId": "tss_match_phrase_query"
|
|
}
|
|
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url, params=params, headers=headers, timeout=15) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
news_data = data.get("news", [])
|
|
|
|
for item in news_data[:limit]:
|
|
news_list.append({
|
|
"title": item.get("title", ""),
|
|
"description": item.get("summary", ""),
|
|
"url": item.get("link", ""),
|
|
"source": item.get("publisher", "Yahoo Finance"),
|
|
"published_at": datetime.fromtimestamp(
|
|
item.get("providerPublishTime", datetime.now().timestamp())
|
|
).isoformat(),
|
|
"sentiment": analyze_sentiment(item.get("title", "") + " " + item.get("summary", ""))
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"获取新闻失败: {e}")
|
|
|
|
# 如果没有获取到新闻,返回模拟数据
|
|
if not news_list:
|
|
news_list = generate_sample_news(symbol or query or "market", limit)
|
|
|
|
return news_list
|
|
|
|
|
|
def analyze_sentiment(text: str) -> str:
|
|
"""简单的情感分析"""
|
|
positive_words = ["surge", "gain", "rise", "up", "bullish", "growth", "profit", "beat", "record", "high"]
|
|
negative_words = ["fall", "drop", "decline", "down", "bearish", "loss", "miss", "low", "crash", "sell"]
|
|
|
|
text_lower = text.lower()
|
|
positive_count = sum(1 for word in positive_words if word in text_lower)
|
|
negative_count = sum(1 for word in negative_words if word in text_lower)
|
|
|
|
if positive_count > negative_count:
|
|
return "positive"
|
|
elif negative_count > positive_count:
|
|
return "negative"
|
|
else:
|
|
return "neutral"
|
|
|
|
|
|
def generate_sample_news(topic: str, limit: int) -> List[Dict[str, Any]]:
|
|
"""生成示例新闻(当 API 不可用时)"""
|
|
sample_news = [
|
|
{
|
|
"title": f"{topic.upper()} Stock Shows Strong Momentum in Pre-Market Trading",
|
|
"description": f"Analysts remain bullish on {topic.upper()} as the stock shows continued strength.",
|
|
"url": "https://finance.yahoo.com/",
|
|
"source": "Yahoo Finance",
|
|
"published_at": datetime.utcnow().isoformat(),
|
|
"sentiment": "positive"
|
|
},
|
|
{
|
|
"title": f"Market Analysis: {topic.upper()} Technical Indicators Point to Potential Breakout",
|
|
"description": "Technical analysts identify key support and resistance levels for upcoming trading sessions.",
|
|
"url": "https://finance.yahoo.com/",
|
|
"source": "Market Watch",
|
|
"published_at": (datetime.utcnow() - timedelta(hours=2)).isoformat(),
|
|
"sentiment": "positive"
|
|
},
|
|
{
|
|
"title": f"Institutional Investors Increase Holdings in {topic.upper()}",
|
|
"description": "Latest 13F filings reveal increased institutional interest in the stock.",
|
|
"url": "https://finance.yahoo.com/",
|
|
"source": "Bloomberg",
|
|
"published_at": (datetime.utcnow() - timedelta(hours=4)).isoformat(),
|
|
"sentiment": "positive"
|
|
},
|
|
{
|
|
"title": f"Wall Street Analysts Update Price Targets for {topic.upper()}",
|
|
"description": "Multiple analysts revise their price targets following recent earnings report.",
|
|
"url": "https://finance.yahoo.com/",
|
|
"source": "CNBC",
|
|
"published_at": (datetime.utcnow() - timedelta(hours=6)).isoformat(),
|
|
"sentiment": "neutral"
|
|
},
|
|
{
|
|
"title": f"Options Activity Surges for {topic.upper()} Ahead of Key Events",
|
|
"description": "Unusual options activity detected as traders position for upcoming catalysts.",
|
|
"url": "https://finance.yahoo.com/",
|
|
"source": "Seeking Alpha",
|
|
"published_at": (datetime.utcnow() - timedelta(hours=8)).isoformat(),
|
|
"sentiment": "neutral"
|
|
}
|
|
]
|
|
return sample_news[:limit]
|
|
|
|
|
|
async def get_market_movers() -> Dict[str, Any]:
|
|
"""获取市场涨跌排行"""
|
|
# 使用 Yahoo Finance 获取市场数据
|
|
url = "https://query1.finance.yahoo.com/v1/finance/trending/US"
|
|
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url, headers=headers, timeout=15) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
quotes = data.get("finance", {}).get("result", [{}])[0].get("quotes", [])
|
|
|
|
return {
|
|
"top_gainers": [{"symbol": q.get("symbol")} for q in quotes[:5]],
|
|
"top_losers": [],
|
|
"most_active": [{"symbol": q.get("symbol")} for q in quotes[:5]]
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"获取市场数据失败: {e}")
|
|
|
|
# 返回默认数据
|
|
return {
|
|
"top_gainers": [
|
|
{"symbol": "NVDA", "change_percent": 5.2},
|
|
{"symbol": "TSLA", "change_percent": 3.8},
|
|
{"symbol": "AMD", "change_percent": 2.9}
|
|
],
|
|
"top_losers": [
|
|
{"symbol": "INTC", "change_percent": -2.1},
|
|
{"symbol": "BA", "change_percent": -1.8}
|
|
],
|
|
"most_active": [
|
|
{"symbol": "AAPL", "volume": "85M"},
|
|
{"symbol": "TSLA", "volume": "72M"},
|
|
{"symbol": "NVDA", "volume": "65M"}
|
|
]
|
|
}
|
|
|
|
|
|
# ==================== API 端点 ====================
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
@app.get("/", response_model=HealthResponse)
|
|
async def health_check():
|
|
"""健康检查"""
|
|
return HealthResponse(
|
|
status="healthy",
|
|
pod_name=POD_NAME,
|
|
news_api_configured=bool(NEWS_API_KEY),
|
|
callback_enabled=CALLBACK_ENABLED,
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
|
|
|
|
@app.post("/news", response_model=NewsResponse)
|
|
async def get_news(request: NewsRequest):
|
|
"""获取股票新闻"""
|
|
if not request.symbol and not request.query:
|
|
raise HTTPException(status_code=400, detail="请提供股票代码(symbol)或搜索关键词(query)")
|
|
|
|
# 使用回调上下文管理器
|
|
if CALLBACK_ENABLED and callback_handler and request.user_id:
|
|
with CallbackContextManager(
|
|
handler=callback_handler,
|
|
user_id=request.user_id,
|
|
request_id=f"stock-news-{int(datetime.utcnow().timestamp())}"
|
|
) as ctx:
|
|
ctx.add_tool("stock_news")
|
|
ctx.add_tool("news_aggregation")
|
|
|
|
news_data = await fetch_yahoo_news(request.symbol, request.query, request.limit)
|
|
|
|
news_items = [NewsItem(**item) for item in news_data]
|
|
|
|
return NewsResponse(
|
|
symbol=request.symbol,
|
|
query=request.query,
|
|
news=news_items,
|
|
total_count=len(news_items),
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
else:
|
|
news_data = await fetch_yahoo_news(request.symbol, request.query, request.limit)
|
|
news_items = [NewsItem(**item) for item in news_data]
|
|
|
|
return NewsResponse(
|
|
symbol=request.symbol,
|
|
query=request.query,
|
|
news=news_items,
|
|
total_count=len(news_items),
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
|
|
|
|
@app.get("/news")
|
|
async def get_news_get(
|
|
symbol: Optional[str] = Query(None, description="股票代码"),
|
|
query: Optional[str] = Query(None, description="搜索关键词"),
|
|
limit: int = Query(10, ge=1, le=50, description="返回数量"),
|
|
user_id: Optional[str] = Query(None, description="用户ID")
|
|
):
|
|
"""GET 方式获取新闻"""
|
|
request = NewsRequest(symbol=symbol, query=query, limit=limit, user_id=user_id)
|
|
return await get_news(request)
|
|
|
|
|
|
@app.get("/market-summary", response_model=MarketSummaryResponse)
|
|
async def get_market_summary():
|
|
"""获取市场概要"""
|
|
movers = await get_market_movers()
|
|
|
|
# 判断市场状态(简单逻辑)
|
|
now = datetime.utcnow()
|
|
hour = now.hour
|
|
weekday = now.weekday()
|
|
|
|
if weekday >= 5: # 周末
|
|
market_status = "closed"
|
|
elif 13 <= hour < 21: # UTC 时间对应美东 9:30-16:00
|
|
market_status = "open"
|
|
elif 9 <= hour < 13: # 盘前
|
|
market_status = "pre-market"
|
|
elif 21 <= hour < 25: # 盘后
|
|
market_status = "after-hours"
|
|
else:
|
|
market_status = "closed"
|
|
|
|
return MarketSummaryResponse(
|
|
market_status=market_status,
|
|
top_gainers=movers["top_gainers"],
|
|
top_losers=movers["top_losers"],
|
|
most_active=movers["most_active"],
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
|
|
|
|
@app.get("/trending")
|
|
async def get_trending_news():
|
|
"""获取热门财经新闻"""
|
|
news_data = await fetch_yahoo_news(query="stock market US", limit=20)
|
|
news_items = [NewsItem(**item) for item in news_data]
|
|
|
|
return NewsResponse(
|
|
query="trending",
|
|
news=news_items,
|
|
total_count=len(news_items),
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
|
|
|
|
# ==================== 主入口 ====================
|
|
|
|
def main():
|
|
"""主函数"""
|
|
logger.info(f"启动 Stock News Agent - {POD_NAME}")
|
|
logger.info(f"News API: {'已配置' if NEWS_API_KEY else '使用免费源'}")
|
|
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
|
|
|
|
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|