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