From f84a99db3c2084313f4d2b68d028e21fab3c9235 Mon Sep 17 00:00:00 2001 From: zhanggangyong Date: Thu, 5 Feb 2026 13:00:59 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Stock=20Quote=20Agent=20-?= =?UTF-8?q?=20=E7=BE=8E=E8=82=A1=E5=AE=9E=E6=97=B6=E8=A1=8C=E6=83=85?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 39 +++++ README.md | 39 ++++- requirements.txt | 5 + stock_quote_agent.py | 350 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 requirements.txt create mode 100644 stock_quote_agent.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..173475f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 依赖 +RUN pip install --no-cache-dir \ + fastapi==0.109.0 \ + uvicorn[standard]==0.27.0 \ + pydantic==2.5.3 \ + requests>=2.31.0 \ + aiohttp>=3.9.0 + +# 复制 common 模块(回调工具) +COPY common/agent_callback_utils.py /app/common/ +RUN touch /app/common/__init__.py + +# 复制应用代码 +COPY agents/stock_quote_agent/stock_quote_agent.py /app/ + +# 环境变量 +ENV PYTHONUNBUFFERED=1 +ENV SERVICE_HOST=0.0.0.0 +ENV SERVICE_PORT=8080 + +# 回调配置 +ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health').read()" || exit 1 + +EXPOSE 8080 + +CMD ["python3", "-u", "stock_quote_agent.py"] diff --git a/README.md b/README.md index 1cd4dae..a8a0d5b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ -# stock-quote-agent +# Stock Quote Agent -美股实时行情查询 Agent - 获取股票价格、涨跌幅、成交量等数据 \ No newline at end of file +美股实时行情查询 Agent - 获取股票价格、涨跌幅、成交量等数据。 + +## 功能特性 + +- 📈 实时股票行情查询 +- 📊 批量行情查询 +- 🔥 热门股票行情 +- 💹 52周高低价 +- 📉 市值和市盈率 + +## API 端点 + +- `GET /` - 健康检查 +- `GET /quote?symbol=AAPL` - 获取单个股票行情 +- `POST /quote` - 获取单个股票行情 +- `POST /batch` - 批量获取股票行情 +- `GET /popular` - 获取热门股票行情 + +## 使用示例 + +```bash +# 获取苹果股票行情 +curl "http://localhost:8080/quote?symbol=AAPL" + +# 批量查询 +curl -X POST "http://localhost:8080/batch" \ + -H "Content-Type: application/json" \ + -d '{"symbols": ["AAPL", "MSFT", "TSLA"]}' +``` + +## 部署 + +```bash +docker build -t stock-quote-agent -f Dockerfile . +docker run -p 8080:8080 stock-quote-agent +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5b98244 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic==2.5.3 +requests>=2.31.0 +aiohttp>=3.9.0 diff --git a/stock_quote_agent.py b/stock_quote_agent.py new file mode 100644 index 0000000..8f7d96c --- /dev/null +++ b/stock_quote_agent.py @@ -0,0 +1,350 @@ +""" +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()