351 lines
12 KiB
Python
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()
|