Files
stock-quote-agent/stock_quote_agent.py

351 lines
12 KiB
Python

"""
Stock Quote Agent - 美股实时行情查询 Agent
使用 Yahoo Finance API 获取美股实时行情数据
"""
import os
import sys
import logging
import aiohttp
from typing import Optional, List, Dict, Any
from datetime import datetime
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-quote-agent")
USER_ID = os.getenv("USER_ID", "")
# Yahoo Finance API (通过 RapidAPI)
RAPIDAPI_KEY = os.getenv("RAPIDAPI_KEY", "")
YAHOO_FINANCE_HOST = "yahoo-finance15.p.rapidapi.com"
# FastAPI 应用
app = FastAPI(
title="Stock Quote 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 QuoteRequest(BaseModel):
"""行情查询请求"""
symbol: str = Field(..., description="股票代码,如 AAPL, TSLA, MSFT")
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
class QuoteResponse(BaseModel):
"""行情查询响应"""
symbol: str
name: str
price: float
change: float
change_percent: float
volume: int
market_cap: Optional[float] = None
pe_ratio: Optional[float] = None
high_52week: Optional[float] = None
low_52week: Optional[float] = None
timestamp: str
class BatchQuoteRequest(BaseModel):
"""批量行情查询请求"""
symbols: List[str] = Field(..., description="股票代码列表")
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
class BatchQuoteResponse(BaseModel):
"""批量行情查询响应"""
quotes: List[QuoteResponse]
success_count: int
failed_count: int
timestamp: str
class HealthResponse(BaseModel):
"""健康检查响应"""
status: str
pod_name: str
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_stock_quote(symbol: str) -> Dict[str, Any]:
"""获取股票行情数据"""
# 使用免费的 Yahoo Finance API 替代方案
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
params = {
"interval": "1d",
"range": "1d"
}
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()
result = data.get("chart", {}).get("result", [])
if not result:
return {"success": False, "error": f"未找到股票: {symbol}"}
quote_data = result[0]
meta = quote_data.get("meta", {})
indicators = quote_data.get("indicators", {}).get("quote", [{}])[0]
current_price = meta.get("regularMarketPrice", 0)
previous_close = meta.get("previousClose", 0)
change = current_price - previous_close if previous_close else 0
change_percent = (change / previous_close * 100) if previous_close else 0
return {
"success": True,
"symbol": symbol.upper(),
"name": meta.get("shortName", symbol),
"price": current_price,
"change": round(change, 2),
"change_percent": round(change_percent, 2),
"volume": indicators.get("volume", [0])[-1] if indicators.get("volume") else 0,
"market_cap": meta.get("marketCap"),
"pe_ratio": None,
"high_52week": meta.get("fiftyTwoWeekHigh"),
"low_52week": meta.get("fiftyTwoWeekLow")
}
else:
return {"success": False, "error": f"API 请求失败: HTTP {response.status}"}
except Exception as e:
logger.error(f"获取行情失败: {symbol} - {e}")
return {"success": False, "error": str(e)}
# ==================== API 端点 ====================
@app.get("/health", response_model=HealthResponse)
@app.get("/", response_model=HealthResponse)
async def health_check():
"""健康检查"""
return HealthResponse(
status="healthy",
pod_name=POD_NAME,
api_configured=True, # 使用免费 API
callback_enabled=CALLBACK_ENABLED,
timestamp=datetime.utcnow().isoformat()
)
@app.post("/quote", response_model=QuoteResponse)
async def get_quote(request: QuoteRequest):
"""获取单个股票行情"""
# 使用回调上下文管理器
if CALLBACK_ENABLED and callback_handler and request.user_id:
with CallbackContextManager(
handler=callback_handler,
user_id=request.user_id,
request_id=f"stock-quote-{int(datetime.utcnow().timestamp())}"
) as ctx:
ctx.add_tool("stock_quote")
ctx.add_tool("yahoo_finance")
result = await fetch_stock_quote(request.symbol)
if not result["success"]:
raise HTTPException(status_code=500, detail=result.get("error"))
return QuoteResponse(
symbol=result["symbol"],
name=result["name"],
price=result["price"],
change=result["change"],
change_percent=result["change_percent"],
volume=result["volume"],
market_cap=result.get("market_cap"),
pe_ratio=result.get("pe_ratio"),
high_52week=result.get("high_52week"),
low_52week=result.get("low_52week"),
timestamp=datetime.utcnow().isoformat()
)
else:
result = await fetch_stock_quote(request.symbol)
if not result["success"]:
raise HTTPException(status_code=500, detail=result.get("error"))
return QuoteResponse(
symbol=result["symbol"],
name=result["name"],
price=result["price"],
change=result["change"],
change_percent=result["change_percent"],
volume=result["volume"],
market_cap=result.get("market_cap"),
pe_ratio=result.get("pe_ratio"),
high_52week=result.get("high_52week"),
low_52week=result.get("low_52week"),
timestamp=datetime.utcnow().isoformat()
)
@app.get("/quote")
async def get_quote_get(
symbol: str = Query(..., description="股票代码"),
user_id: Optional[str] = Query(None, description="用户ID")
):
"""GET 方式获取行情"""
request = QuoteRequest(symbol=symbol, user_id=user_id)
return await get_quote(request)
@app.post("/batch", response_model=BatchQuoteResponse)
async def batch_get_quotes(request: BatchQuoteRequest):
"""批量获取股票行情"""
if CALLBACK_ENABLED and callback_handler and request.user_id:
with CallbackContextManager(
handler=callback_handler,
user_id=request.user_id,
request_id=f"stock-batch-{int(datetime.utcnow().timestamp())}"
) as ctx:
ctx.add_tool("stock_quote")
ctx.add_tool("batch_quote")
quotes = []
success_count = 0
failed_count = 0
for symbol in request.symbols:
result = await fetch_stock_quote(symbol)
if result["success"]:
quotes.append(QuoteResponse(
symbol=result["symbol"],
name=result["name"],
price=result["price"],
change=result["change"],
change_percent=result["change_percent"],
volume=result["volume"],
market_cap=result.get("market_cap"),
pe_ratio=result.get("pe_ratio"),
high_52week=result.get("high_52week"),
low_52week=result.get("low_52week"),
timestamp=datetime.utcnow().isoformat()
))
success_count += 1
else:
failed_count += 1
return BatchQuoteResponse(
quotes=quotes,
success_count=success_count,
failed_count=failed_count,
timestamp=datetime.utcnow().isoformat()
)
else:
quotes = []
success_count = 0
failed_count = 0
for symbol in request.symbols:
result = await fetch_stock_quote(symbol)
if result["success"]:
quotes.append(QuoteResponse(
symbol=result["symbol"],
name=result["name"],
price=result["price"],
change=result["change"],
change_percent=result["change_percent"],
volume=result["volume"],
market_cap=result.get("market_cap"),
pe_ratio=result.get("pe_ratio"),
high_52week=result.get("high_52week"),
low_52week=result.get("low_52week"),
timestamp=datetime.utcnow().isoformat()
))
success_count += 1
else:
failed_count += 1
return BatchQuoteResponse(
quotes=quotes,
success_count=success_count,
failed_count=failed_count,
timestamp=datetime.utcnow().isoformat()
)
@app.get("/popular")
async def get_popular_stocks():
"""获取热门股票行情"""
popular_symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"]
request = BatchQuoteRequest(symbols=popular_symbols)
return await batch_get_quotes(request)
# ==================== 主入口 ====================
def main():
"""主函数"""
logger.info(f"启动 Stock Quote Agent - {POD_NAME}")
logger.info(f"回调功能: {'已启用' if CALLBACK_ENABLED else '未启用'}")
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
if __name__ == "__main__":
main()