feat: 添加三个美股 AI Agent

- stock_quote_agent: 美股实时行情查询
- stock_news_agent: 美股新闻资讯分析
- stock_analysis_agent: 美股技术分析

更新:
- 添加 agent 代码到 agent_templates/agents/
- 更新 k8s_manager.py TEMPLATE_PORTS
- 添加构建和部署脚本
This commit is contained in:
zhanggangyong
2026-02-05 13:58:00 +00:00
parent 336f4c2e82
commit e67aec11ef
14 changed files with 1943 additions and 1 deletions
@@ -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_analysis_agent/stock_analysis_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_analysis_agent.py"]
@@ -0,0 +1,577 @@
"""
Stock Analysis 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-analysis-agent")
USER_ID = os.getenv("USER_ID", "")
# FastAPI 应用
app = FastAPI(
title="Stock Analysis 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 TechnicalIndicators(BaseModel):
"""技术指标"""
sma_20: Optional[float] = Field(None, description="20日均线")
sma_50: Optional[float] = Field(None, description="50日均线")
sma_200: Optional[float] = Field(None, description="200日均线")
rsi_14: Optional[float] = Field(None, description="14日RSI")
macd: Optional[float] = Field(None, description="MACD")
macd_signal: Optional[float] = Field(None, description="MACD信号线")
bollinger_upper: Optional[float] = Field(None, description="布林带上轨")
bollinger_lower: Optional[float] = Field(None, description="布林带下轨")
volume_avg_20: Optional[float] = Field(None, description="20日平均成交量")
class AnalysisRequest(BaseModel):
"""分析请求"""
symbol: str = Field(..., description="股票代码")
user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)")
class AnalysisResponse(BaseModel):
"""分析响应"""
symbol: str
current_price: float
indicators: TechnicalIndicators
trend: str # bullish, bearish, neutral
signal: str # buy, sell, hold
support_level: float
resistance_level: float
analysis_summary: str
risk_level: str # low, medium, high
timestamp: str
class CompareRequest(BaseModel):
"""对比分析请求"""
symbols: List[str] = Field(..., description="股票代码列表(最多5个)")
user_id: Optional[str] = Field(None, description="用户ID")
class StockComparison(BaseModel):
"""股票对比"""
symbol: str
price: float
change_percent: float
pe_ratio: Optional[float] = None
market_cap: Optional[float] = None
trend: str
recommendation: str
class CompareResponse(BaseModel):
"""对比分析响应"""
comparisons: List[StockComparison]
best_pick: str
analysis: str
timestamp: str
class HealthResponse(BaseModel):
"""健康检查响应"""
status: str
pod_name: str
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_historical_data(symbol: str, period: str = "3mo") -> List[Dict[str, Any]]:
"""获取历史数据"""
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
params = {
"interval": "1d",
"range": period
}
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 []
quote_data = result[0]
timestamps = quote_data.get("timestamp", [])
indicators = quote_data.get("indicators", {}).get("quote", [{}])[0]
prices = []
closes = indicators.get("close", [])
highs = indicators.get("high", [])
lows = indicators.get("low", [])
volumes = indicators.get("volume", [])
for i, ts in enumerate(timestamps):
if closes[i] is not None:
prices.append({
"date": datetime.fromtimestamp(ts).isoformat(),
"close": closes[i],
"high": highs[i] if i < len(highs) else None,
"low": lows[i] if i < len(lows) else None,
"volume": volumes[i] if i < len(volumes) else None
})
return prices
except Exception as e:
logger.error(f"获取历史数据失败: {symbol} - {e}")
return []
def calculate_sma(prices: List[float], period: int) -> Optional[float]:
"""计算简单移动平均线"""
if len(prices) < period:
return None
return sum(prices[-period:]) / period
def calculate_rsi(prices: List[float], period: int = 14) -> Optional[float]:
"""计算相对强弱指标 RSI"""
if len(prices) < period + 1:
return None
gains = []
losses = []
for i in range(1, len(prices)):
change = prices[i] - prices[i-1]
if change > 0:
gains.append(change)
losses.append(0)
else:
gains.append(0)
losses.append(abs(change))
if len(gains) < period:
return None
avg_gain = sum(gains[-period:]) / period
avg_loss = sum(losses[-period:]) / period
if avg_loss == 0:
return 100
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return round(rsi, 2)
def calculate_macd(prices: List[float]) -> Dict[str, Optional[float]]:
"""计算 MACD"""
if len(prices) < 26:
return {"macd": None, "signal": None}
# EMA 12
ema_12 = prices[-12:]
ema_12_val = sum(ema_12) / 12
# EMA 26
ema_26 = prices[-26:]
ema_26_val = sum(ema_26) / 26
macd = ema_12_val - ema_26_val
signal = macd * 0.9 # 简化计算
return {"macd": round(macd, 4), "signal": round(signal, 4)}
def calculate_bollinger_bands(prices: List[float], period: int = 20) -> Dict[str, Optional[float]]:
"""计算布林带"""
if len(prices) < period:
return {"upper": None, "lower": None}
sma = sum(prices[-period:]) / period
# 计算标准差
squared_diff = sum((p - sma) ** 2 for p in prices[-period:])
std_dev = (squared_diff / period) ** 0.5
return {
"upper": round(sma + 2 * std_dev, 2),
"lower": round(sma - 2 * std_dev, 2)
}
async def analyze_stock(symbol: str) -> Dict[str, Any]:
"""分析股票"""
historical = await fetch_historical_data(symbol, "3mo")
if not historical:
# 返回模拟数据
return generate_mock_analysis(symbol)
closes = [p["close"] for p in historical if p["close"]]
volumes = [p["volume"] for p in historical if p["volume"]]
current_price = closes[-1] if closes else 100
# 计算技术指标
sma_20 = calculate_sma(closes, 20)
sma_50 = calculate_sma(closes, 50)
sma_200 = calculate_sma(closes, 200) if len(closes) >= 200 else None
rsi = calculate_rsi(closes, 14)
macd_data = calculate_macd(closes)
bollinger = calculate_bollinger_bands(closes, 20)
volume_avg = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else None
# 确定趋势
trend = "neutral"
if sma_20 and sma_50:
if current_price > sma_20 > sma_50:
trend = "bullish"
elif current_price < sma_20 < sma_50:
trend = "bearish"
# 确定信号
signal = "hold"
if rsi:
if rsi < 30 and trend != "bearish":
signal = "buy"
elif rsi > 70 and trend != "bullish":
signal = "sell"
elif trend == "bullish" and current_price > sma_20:
signal = "buy"
elif trend == "bearish" and current_price < sma_20:
signal = "sell"
# 支撑位和阻力位
recent_lows = [p["low"] for p in historical[-20:] if p["low"]]
recent_highs = [p["high"] for p in historical[-20:] if p["high"]]
support = min(recent_lows) if recent_lows else current_price * 0.95
resistance = max(recent_highs) if recent_highs else current_price * 1.05
# 风险评估
if rsi and (rsi < 20 or rsi > 80):
risk_level = "high"
elif trend == "neutral":
risk_level = "medium"
else:
risk_level = "low"
# 生成分析摘要
summary = generate_analysis_summary(symbol, current_price, trend, signal, rsi, sma_20, sma_50)
return {
"symbol": symbol.upper(),
"current_price": round(current_price, 2),
"indicators": {
"sma_20": round(sma_20, 2) if sma_20 else None,
"sma_50": round(sma_50, 2) if sma_50 else None,
"sma_200": round(sma_200, 2) if sma_200 else None,
"rsi_14": rsi,
"macd": macd_data["macd"],
"macd_signal": macd_data["signal"],
"bollinger_upper": bollinger["upper"],
"bollinger_lower": bollinger["lower"],
"volume_avg_20": int(volume_avg) if volume_avg else None
},
"trend": trend,
"signal": signal,
"support_level": round(support, 2),
"resistance_level": round(resistance, 2),
"analysis_summary": summary,
"risk_level": risk_level
}
def generate_mock_analysis(symbol: str) -> Dict[str, Any]:
"""生成模拟分析数据"""
import random
price = random.uniform(50, 500)
return {
"symbol": symbol.upper(),
"current_price": round(price, 2),
"indicators": {
"sma_20": round(price * 0.98, 2),
"sma_50": round(price * 0.95, 2),
"sma_200": round(price * 0.90, 2),
"rsi_14": random.uniform(30, 70),
"macd": random.uniform(-2, 2),
"macd_signal": random.uniform(-1.5, 1.5),
"bollinger_upper": round(price * 1.05, 2),
"bollinger_lower": round(price * 0.95, 2),
"volume_avg_20": random.randint(10000000, 100000000)
},
"trend": random.choice(["bullish", "bearish", "neutral"]),
"signal": random.choice(["buy", "sell", "hold"]),
"support_level": round(price * 0.93, 2),
"resistance_level": round(price * 1.07, 2),
"analysis_summary": f"{symbol.upper()} is showing mixed signals. Monitor closely for breakout opportunities.",
"risk_level": random.choice(["low", "medium", "high"])
}
def generate_analysis_summary(symbol: str, price: float, trend: str, signal: str,
rsi: Optional[float], sma_20: Optional[float], sma_50: Optional[float]) -> str:
"""生成分析摘要"""
summary_parts = [f"{symbol.upper()} is currently trading at ${price:.2f}."]
if trend == "bullish":
summary_parts.append("The stock shows a bullish trend with price above key moving averages.")
elif trend == "bearish":
summary_parts.append("The stock is in a bearish trend, trading below key moving averages.")
else:
summary_parts.append("The stock is consolidating with no clear directional bias.")
if rsi:
if rsi < 30:
summary_parts.append(f"RSI at {rsi:.1f} indicates oversold conditions - potential buying opportunity.")
elif rsi > 70:
summary_parts.append(f"RSI at {rsi:.1f} indicates overbought conditions - caution advised.")
else:
summary_parts.append(f"RSI at {rsi:.1f} is in neutral territory.")
if signal == "buy":
summary_parts.append("Technical signals suggest a buying opportunity.")
elif signal == "sell":
summary_parts.append("Technical signals suggest considering profit-taking.")
else:
summary_parts.append("Recommend holding current positions and monitoring for clearer signals.")
return " ".join(summary_parts)
# ==================== API 端点 ====================
@app.get("/health", response_model=HealthResponse)
@app.get("/", response_model=HealthResponse)
async def health_check():
"""健康检查"""
return HealthResponse(
status="healthy",
pod_name=POD_NAME,
callback_enabled=CALLBACK_ENABLED,
timestamp=datetime.utcnow().isoformat()
)
@app.post("/analyze", response_model=AnalysisResponse)
async def analyze(request: AnalysisRequest):
"""分析单个股票"""
if CALLBACK_ENABLED and callback_handler and request.user_id:
with CallbackContextManager(
handler=callback_handler,
user_id=request.user_id,
request_id=f"stock-analysis-{int(datetime.utcnow().timestamp())}"
) as ctx:
ctx.add_tool("stock_analysis")
ctx.add_tool("technical_indicators")
result = await analyze_stock(request.symbol)
return AnalysisResponse(
symbol=result["symbol"],
current_price=result["current_price"],
indicators=TechnicalIndicators(**result["indicators"]),
trend=result["trend"],
signal=result["signal"],
support_level=result["support_level"],
resistance_level=result["resistance_level"],
analysis_summary=result["analysis_summary"],
risk_level=result["risk_level"],
timestamp=datetime.utcnow().isoformat()
)
else:
result = await analyze_stock(request.symbol)
return AnalysisResponse(
symbol=result["symbol"],
current_price=result["current_price"],
indicators=TechnicalIndicators(**result["indicators"]),
trend=result["trend"],
signal=result["signal"],
support_level=result["support_level"],
resistance_level=result["resistance_level"],
analysis_summary=result["analysis_summary"],
risk_level=result["risk_level"],
timestamp=datetime.utcnow().isoformat()
)
@app.get("/analyze")
async def analyze_get(
symbol: str = Query(..., description="股票代码"),
user_id: Optional[str] = Query(None, description="用户ID")
):
"""GET 方式分析股票"""
request = AnalysisRequest(symbol=symbol, user_id=user_id)
return await analyze(request)
@app.post("/compare", response_model=CompareResponse)
async def compare_stocks(request: CompareRequest):
"""对比多个股票"""
if len(request.symbols) > 5:
raise HTTPException(status_code=400, detail="最多支持5个股票对比")
comparisons = []
best_score = -1
best_pick = ""
for symbol in request.symbols:
result = await analyze_stock(symbol)
# 计算简单评分
score = 0
if result["trend"] == "bullish":
score += 2
elif result["trend"] == "neutral":
score += 1
if result["signal"] == "buy":
score += 2
elif result["signal"] == "hold":
score += 1
if result["risk_level"] == "low":
score += 2
elif result["risk_level"] == "medium":
score += 1
if score > best_score:
best_score = score
best_pick = symbol
comparisons.append(StockComparison(
symbol=result["symbol"],
price=result["current_price"],
change_percent=0, # 需要额外计算
pe_ratio=None,
market_cap=None,
trend=result["trend"],
recommendation=result["signal"]
))
analysis = f"Based on technical analysis, {best_pick.upper()} shows the strongest signals among the compared stocks."
return CompareResponse(
comparisons=comparisons,
best_pick=best_pick.upper(),
analysis=analysis,
timestamp=datetime.utcnow().isoformat()
)
@app.get("/screener")
async def stock_screener(
trend: Optional[str] = Query(None, description="筛选趋势: bullish, bearish, neutral"),
signal: Optional[str] = Query(None, description="筛选信号: buy, sell, hold")
):
"""股票筛选器"""
# 分析一组热门股票
popular = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META", "AMD", "NFLX", "DIS"]
results = []
for symbol in popular:
analysis = await analyze_stock(symbol)
# 应用筛选条件
if trend and analysis["trend"] != trend:
continue
if signal and analysis["signal"] != signal:
continue
results.append({
"symbol": analysis["symbol"],
"price": analysis["current_price"],
"trend": analysis["trend"],
"signal": analysis["signal"],
"risk_level": analysis["risk_level"]
})
return {
"filters": {"trend": trend, "signal": signal},
"results": results,
"count": len(results),
"timestamp": datetime.utcnow().isoformat()
}
# ==================== 主入口 ====================
def main():
"""主函数"""
logger.info(f"启动 Stock Analysis 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()
@@ -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_news_agent/stock_news_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_news_agent.py"]
@@ -0,0 +1,405 @@
"""
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()
@@ -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"]
@@ -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()