stock-ai-agent/backend/app/stock_agent/stock_agent.py
2026-02-20 10:02:00 +08:00

535 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
美股交易智能体 - 主控制器LLM 驱动版)
只进行市场分析和通知,不执行模拟交易
"""
import asyncio
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
import pandas as pd
from app.utils.logger import logger
from app.config import get_settings
from app.services.yfinance_service import get_yfinance_service
from app.services.feishu_service import get_feishu_service
from app.services.telegram_service import get_telegram_service
from app.services.signal_database_service import get_signal_db_service
from app.crypto_agent.llm_signal_analyzer import LLMSignalAnalyzer
class StockAgent:
"""美股交易信号智能体LLM 驱动,仅分析通知)"""
def __init__(self):
"""初始化智能体"""
self.settings = get_settings()
self.yfinance = get_yfinance_service()
self.feishu = get_feishu_service()
self.telegram = get_telegram_service()
self.llm_analyzer = LLMSignalAnalyzer(agent_type="stock") # 指定使用 stock 模型配置
self.signal_db = get_signal_db_service() # 信号数据库服务
# 状态管理
self.last_signals: Dict[str, Dict[str, Any]] = {}
self.signal_cooldown: Dict[str, datetime] = {}
# 配置
self.symbols = self.settings.stock_symbols.split(',')
# 运行状态
self.running = False
self._event_loop = None
self._task = None
logger.info(f"美股智能体初始化完成,监控股票: {self.symbols}")
async def start(self):
"""启动智能体"""
if self.running:
logger.warning("美股智能体已在运行中")
return
self.running = True
self._event_loop = asyncio.get_event_loop()
logger.info("美股智能体已启动")
# 启动分析任务
self._task = asyncio.create_task(self._analysis_loop())
async def stop(self):
"""停止智能体"""
self.running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("美股智能体已停止")
async def _analysis_loop(self):
"""分析循环 - 只在美股交易时间内运行,整点执行"""
while self.running:
try:
# 计算距离下一个整点的时间
now = datetime.now()
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
wait_seconds = (next_hour - now).total_seconds()
logger.info(f"等待到下一个整点: {next_hour.strftime('%H:%M')} (等待 {int(wait_seconds)} 秒)")
# 等待到整点
await asyncio.sleep(wait_seconds)
# 检查是否在美股交易时间
if not self._is_market_hours():
logger.debug("非美股交易时间,跳过本次分析")
# 继续等待下一个整点
continue
# 在交易时间内,分析所有股票并收集结果
logger.info(f"开始分析 {len(self.symbols)} 只股票")
analysis_results = []
for symbol in self.symbols:
if not self.running:
break
result = await self.analyze_symbol(symbol)
if result:
analysis_results.append(result)
# 生成并发送汇总报告
await self._send_summary_report(analysis_results)
logger.info("本次分析完成")
except Exception as e:
logger.error(f"分析循环出错: {e}")
await asyncio.sleep(60) # 出错后等待 1 分钟再重试
def _is_market_hours(self) -> bool:
"""
判断当前是否在美股交易时间
美股交易时间: 周一至周五 9:30-16:00 (EST)
北京时间:
- 冬令时 (11月-3月): 22:30-05:00 (次日)
- 夏令时 (3月-11月): 21:30-04:00 (次日)
Returns:
是否在交易时间
"""
from datetime import datetime
# 获取当前时间
now = datetime.now()
# 检查是否为周末
if now.weekday() >= 5: # 5=周六, 6=周日
return False
# 获取当前小时和分钟
hour = now.hour
minute = now.minute
current_time = hour * 100 + minute # 转换为数字,如 2130 表示 21:30
# 判断夏令时/冬令时简单判断3-11月为夏令时
is_summer = 3 <= now.month <= 11
if is_summer:
# 夏令时: 21:30-04:00 (次日)
# 即 2130-2359 或 0000-0400
if current_time >= 2130 or current_time < 400:
return True
else:
# 冬令时: 22:30-05:00 (次日)
# 即 2230-2359 或 0000-0500
if current_time >= 2230 or current_time < 500:
return True
return False
async def analyze_symbol(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
分析单个股票
Args:
symbol: 股票代码
Returns:
分析结果字典,包含股票信息和信号
"""
result = {
'symbol': symbol,
'current_price': 0,
'signals': [],
'analysis_summary': '',
'notified': False
}
try:
# 1. 获取多时间周期数据
data = self.yfinance.get_multi_timeframe_data(symbol)
# 2. 验证数据完整性
if not self._validate_data(data):
logger.warning(f"{symbol} 数据不完整,跳过本次分析")
return result
# 3. 获取当前价格
ticker = self.yfinance.get_ticker(symbol)
if not ticker:
logger.warning(f"无法获取 {symbol} 当前价格")
return result
current_price = ticker['lastPrice']
result['current_price'] = current_price
logger.info(f"\n{'='*60}")
logger.info(f"📊 分析 {symbol} @ ${current_price:,.2f}")
logger.info(f"{'='*60}")
# 4. LLM 分析
logger.info(f"\n🤖 【LLM 分析中...】")
analysis = await self.llm_analyzer.analyze(
symbol, data,
symbols=self.symbols,
position_info=None # 美股不跟踪持仓
)
# 输出分析摘要
summary = analysis.get('analysis_summary', '')
result['analysis_summary'] = summary
logger.info(f" 市场状态: {summary}")
# 输出新闻情绪
news_sentiment = analysis.get('news_sentiment', '')
news_impact = analysis.get('news_impact', '')
if news_sentiment:
sentiment_icon = {'positive': '📈', 'negative': '📉', 'neutral': ''}.get(news_sentiment, '')
logger.info(f" 新闻情绪: {sentiment_icon} {news_sentiment}")
if news_impact:
logger.info(f" 消息影响: {news_impact}")
# 输出关键价位
levels = analysis.get('key_levels', {})
if levels.get('support') or levels.get('resistance'):
support_str = ', '.join([f"${s:,.2f}" for s in levels.get('support', [])[:2]])
resistance_str = ', '.join([f"${r:,.2f}" for r in levels.get('resistance', [])[:2]])
logger.info(f" 支撑位: {support_str or '-'}")
logger.info(f" 阻力位: {resistance_str or '-'}")
# 5. 处理信号
signals = analysis.get('signals', [])
result['signals'] = signals
if not signals:
logger.info(f"\n⏸️ 结论: 无交易信号,继续观望")
return result
# 输出所有信号
logger.info(f"\n🎯 【发现 {len(signals)} 个信号】")
for sig in signals:
signal_type = sig.get('type', 'unknown')
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
type_text = type_map.get(signal_type, signal_type)
action = sig.get('action', 'wait')
action_map = {'buy': '🟢 做多', 'sell': '🔴 做空'}
action_text = action_map.get(action, action)
grade = sig.get('grade', 'D')
confidence = sig.get('confidence', 0)
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '', 'D': ''}.get(grade, '')
logger.info(f"\n {type_text} {action_text} [{grade}{grade_icon}] {confidence}%")
# 6. 过滤并通知最佳信号
best_signal = self._get_best_signal(signals)
if not best_signal:
logger.info(f"\n⏸️ 信号质量不高,不发送通知")
return result
# 检查置信度阈值
threshold = self.settings.stock_llm_threshold * 100
if best_signal.get('confidence', 0) < threshold:
logger.info(f"\n⏸️ 置信度不足 ({best_signal.get('confidence', 0)}% < {threshold}%)")
return result
# 检查冷却时间
if not self._should_send_signal(symbol, best_signal):
logger.info(f"\n⏸️ 信号冷却中,不发送通知")
return result
# 发送通知
await self._send_signal_notification(symbol, best_signal, current_price)
result['notified'] = True
result['best_signal'] = best_signal
# 更新状态
self.last_signals[symbol] = best_signal
self.signal_cooldown[symbol] = datetime.now()
return result
except Exception as e:
logger.error(f"❌ 分析 {symbol} 出错: {e}")
import traceback
logger.error(traceback.format_exc())
return result
def _get_best_signal(self, signals: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""获取最佳信号"""
# 过滤掉 D 级信号
valid_signals = [s for s in signals if s.get('grade', 'D') != 'D']
if not valid_signals:
return None
# 按等级和置信度排序
grade_order = {'A': 0, 'B': 1, 'C': 2}
valid_signals.sort(key=lambda x: (
grade_order.get(x.get('grade', 'C'), 3),
-x.get('confidence', 0)
))
return valid_signals[0]
def _should_send_signal(self, symbol: str, signal: Dict[str, Any]) -> bool:
"""判断是否应该发送信号"""
action = signal.get('action', 'wait')
if action == 'wait':
return False
# 检查冷却时间60分钟内不重复发送相同方向的信号
if symbol in self.signal_cooldown:
cooldown_end = self.signal_cooldown[symbol] + timedelta(minutes=60)
if datetime.now() < cooldown_end:
if symbol in self.last_signals:
if self.last_signals[symbol].get('action') == action:
logger.debug(f"{symbol} 信号冷却中,跳过")
return False
return True
async def _send_signal_notification(
self,
symbol: str,
signal: Dict[str, Any],
current_price: float
):
"""发送信号通知"""
try:
# 使用正确的方法格式化信号
card = self.llm_analyzer.format_feishu_card(signal, symbol)
title = card['title']
content = card['content']
# 根据信号方向选择颜色
color = "green" if signal.get('action') == 'buy' else "red"
# 发送到飞书
await self.feishu.send_card(title, content, color)
# 发送到 Telegram
await self.telegram.send_message(self.llm_analyzer.format_signal_message(signal, symbol))
logger.info(f"✅ 信号通知已发送: {title}")
# 保存信号到数据库
signal_to_save = signal.copy()
signal_to_save['signal_type'] = 'stock'
signal_to_save['symbol'] = symbol
signal_to_save['current_price'] = current_price
self.signal_db.add_signal(signal_to_save)
except Exception as e:
logger.error(f"发送通知失败: {e}")
def _validate_data(self, data: Dict[str, pd.DataFrame]) -> bool:
"""验证数据完整性"""
required_intervals = ['1d', '1h']
for interval in required_intervals:
if interval not in data or data[interval].empty:
return False
if len(data[interval]) < 20:
return False
return True
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
"""单次分析(用于测试或手动触发)"""
data = self.yfinance.get_multi_timeframe_data(symbol)
if not self._validate_data(data):
return {'error': '数据不完整'}
result = await self.llm_analyzer.analyze(
symbol, data,
symbols=self.symbols,
position_info=None
)
return result
def get_status(self) -> Dict[str, Any]:
"""获取智能体状态"""
return {
'running': self.running,
'symbols': self.symbols,
'mode': 'LLM 驱动(仅分析通知)',
'last_signals': {
symbol: {
'type': sig.get('type'),
'action': sig.get('action'),
'confidence': sig.get('confidence'),
'grade': sig.get('grade')
}
for symbol, sig in self.last_signals.items()
}
}
async def _send_summary_report(self, results: List[Dict[str, Any]]):
"""
生成并发送分析汇总报告
Args:
results: 所有股票的分析结果列表
"""
try:
now = datetime.now()
total = len(results)
with_signals = [r for r in results if r.get('signals')]
notified = [r for r in results if r.get('notified')]
# 统计信号
buy_signals = []
sell_signals = []
high_quality_signals = [] # A/B级信号
for r in with_signals:
for sig in r.get('signals', []):
sig['symbol'] = r['symbol']
sig['current_price'] = r.get('current_price', 0)
if sig.get('action') == 'buy':
buy_signals.append(sig)
elif sig.get('action') == 'sell':
sell_signals.append(sig)
if sig.get('grade') in ['A', 'B']:
high_quality_signals.append(sig)
# 按置信度排序
high_quality_signals.sort(key=lambda x: x.get('confidence', 0), reverse=True)
# 构建汇总报告
logger.info(f"\n{'='*80}")
logger.info(f"📊 美股分析汇总报告")
logger.info(f"{'='*80}")
logger.info(f"时间: {now.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"分析数量: {total} 只股票")
logger.info(f"有信号: {len(with_signals)}")
logger.info(f"已通知: {len(notified)}")
logger.info(f"")
# 显示高等级信号
if high_quality_signals:
logger.info(f"⭐ 高等级信号 (A/B级): {len(high_quality_signals)}")
for sig in high_quality_signals[:10]: # 最多显示10个
symbol = sig['symbol']
action = '🟢 做多' if sig.get('action') == 'buy' else '🔴 做空'
grade = sig.get('grade', 'D')
confidence = sig.get('confidence', 0)
price = sig.get('current_price', 0)
entry = sig.get('entry_price', 0)
logger.info(f" {symbol} {action} [{grade}级] {confidence}% @ ${price:,.2f}")
if entry > 0:
logger.info(f" 入场: ${entry:,.2f}")
logger.info(f"")
# 统计汇总
logger.info(f"📈 做多信号: {len(buy_signals)}")
logger.info(f"📉 做空信号: {len(sell_signals)}")
logger.info(f"{'='*80}\n")
# 发送飞书汇总
await self._send_feishu_summary(
now, total, with_signals, notified,
buy_signals, sell_signals, high_quality_signals
)
except Exception as e:
logger.error(f"生成汇总报告失败: {e}")
import traceback
logger.error(traceback.format_exc())
async def _send_feishu_summary(
self,
now: datetime,
total: int,
with_signals: List,
notified: List,
buy_signals: List,
sell_signals: List,
high_quality_signals: List
):
"""发送飞书汇总报告"""
try:
# 构建内容
content_parts = [
f"**美股分析汇总报告**",
f"",
f"⏰ 时间: {now.strftime('%Y-%m-%d %H:%M')}",
f"",
f"📊 **分析概况**",
f"• 分析总数: {total}",
f"• 发现信号: {len(with_signals)}",
f"• 已发通知: {len(notified)}",
f"",
]
# 高等级信号
if high_quality_signals:
content_parts.append(f"⭐ **高等级信号 (A/B级)**")
for sig in high_quality_signals[:5]:
symbol = sig['symbol']
action = '🟢 做多' if sig.get('action') == 'buy' else '🔴 做空'
grade = sig.get('grade', 'D')
confidence = sig.get('confidence', 0)
content_parts.append(f"{symbol} {action} {grade}{confidence}%")
content_parts.append(f"")
# 信号统计
content_parts.extend([
f"📈 做多信号: {len(buy_signals)}",
f"📉 做空信号: {len(sell_signals)}",
f"",
f"*⚠️ 仅供参考,不构成投资建议*"
])
content = "\n".join(content_parts)
# 发送飞书
title = f"📊 美股分析汇总 ({now.strftime('%H:%M')})"
color = "blue"
await self.feishu.send_card(title, content, color)
logger.info("✅ 汇总报告已发送到飞书")
except Exception as e:
logger.error(f"发送飞书汇总失败: {e}")
# 全局单例
_stock_agent: Optional[StockAgent] = None
def get_stock_agent() -> StockAgent:
"""获取美股智能体单例"""
global _stock_agent
if _stock_agent is None:
_stock_agent = StockAgent()
return _stock_agent