stock-ai-agent/backend/app/main.py
2026-02-27 21:50:26 +08:00

733 lines
30 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.

"""
FastAPI主应用
"""
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from app.config import get_settings
from app.utils.logger import logger
from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals, system, real_trading, news
from app.utils.error_handler import setup_global_exception_handler, init_error_notifier
from app.utils.system_status import get_system_monitor
import os
# 后台任务
_price_monitor_task = None
_stock_agent_task = None
_crypto_agent_task = None
_news_agent_task = None
_astock_monitor_task = None
_astock_scheduler = None
_astock_monitor_instance = None
async def is_trading_day() -> bool:
"""检查今天是否为A股交易日"""
try:
from datetime import datetime
from app.config import get_settings
from app.astock_agent.tushare_client import TushareClient
settings = get_settings()
token = settings.tushare_token
if not token:
logger.warning("Tushare token 未配置,使用简单的周末判断")
# 简单判断:周一到周五是交易日(不包含节假日)
return datetime.now().weekday() < 5
client = TushareClient(token=token)
pro = client.pro
# 获取今天的日期
today = datetime.now().strftime("%Y%m%d")
# 查询交易日历最近3天
df = pro.trade_cal(
exchange='SSE',
start_date=(datetime.now().replace(day=datetime.now().day-2)).strftime("%Y%m%d") if datetime.now().day > 2 else today,
end_date=today
)
if df is not None and not df.empty:
# 检查今天是否为交易日
today_cal = df[df['cal_date'] == today]
if not today_cal.empty:
is_open = today_cal.iloc[0]['is_open']
logger.info(f"交易日历查询: 今天 {today} {'' if is_open == 1 else '不是'}交易日")
return is_open == 1
# Fallback: 简单周末判断
is_weekday = datetime.now().weekday() < 5
logger.warning(f"交易日历查询失败,使用简单判断: 今天 {'' if is_weekday else '不是'}工作日")
return is_weekday
except Exception as e:
logger.error(f"检查交易日失败: {e}")
# Fallback: 简单周末判断
return datetime.now().weekday() < 5
async def run_scheduled_astock_monitor():
"""定时运行A股板块异动监控每天 15:30"""
global _astock_monitor_instance
if not _astock_monitor_instance:
logger.warning("A股监控实例未初始化")
return
try:
# 检查今天是否为交易日
if not await is_trading_day():
logger.info("📅 今天不是交易日,跳过板块异动分析")
return
logger.info("🔔 开始执行定时板块异动分析...")
result = await _astock_monitor_instance.check_once()
hot_sectors = result.get('hot_sectors', 0)
stocks = result.get('stocks', 0)
notified = result.get('notified', 0)
logger.info(f"✅ 定时板块分析完成: {hot_sectors}个异动板块, {stocks}只龙头股, {notified}条通知")
except Exception as e:
logger.error(f"定时板块分析失败: {e}")
async def start_scheduler():
"""启动定时任务调度器"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
global _astock_scheduler
# 创建调度器
_astock_scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
# 添加定时任务:每天 15:30 运行板块异动分析
_astock_scheduler.add_job(
run_scheduled_astock_monitor,
trigger=CronTrigger(hour=15, minute=30),
id='daily_astock_monitor',
name='A股板块异动分析',
replace_existing=True
)
_astock_scheduler.start()
logger.info("📅 定时任务调度器已启动:")
logger.info(" - 每天 15:30 (A股板块异动分析)")
async def price_monitor_loop():
"""后台价格监控循环 - 使用轮询检查止盈止损"""
from app.services.paper_trading_service import get_paper_trading_service
from app.services.bitget_service import bitget_service
from app.services.feishu_service import get_feishu_service
from app.services.telegram_service import get_telegram_service
logger.info("后台价格监控任务已启动(轮询模式)")
feishu = get_feishu_service()
telegram = get_telegram_service()
paper_trading = get_paper_trading_service()
# 价格更新回调 - 检查止盈止损
async def on_price_update(symbol: str, price: float):
"""价格更新时检查止盈止损"""
try:
# 检查止盈止损
triggered = paper_trading.check_price_triggers(symbol, price)
# 发送通知
for result in triggered:
status = result.get('status', '')
event_type = result.get('event_type', 'order_closed')
# 处理挂单成交事件
if event_type == 'order_filled':
side_text = "做多" if result.get('side') == 'long' else "做空"
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
grade = result.get('signal_grade', 'N/A')
symbol = result.get('symbol', '')
entry_price = result.get('entry_price', 0)
filled_price = result.get('filled_price', 0)
stop_loss = result.get('stop_loss', 0)
take_profit = result.get('take_profit', 0)
# 根据交易对精度格式化价格
try:
from app.services.bitget_service import bitget_service
precision = bitget_service.get_precision(symbol)
price_fmt = f"{{:,.{precision['pricePrecision']}f}}"
except:
price_fmt = "{:,.2f}"
title = f"✅ 挂单成交 - {symbol}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"⭐ **信号等级**: {grade}",
f"💰 **挂单价**: ${price_fmt.format(entry_price)}",
f"🎯 **成交价**: ${price_fmt.format(filled_price)}",
f"💵 **仓位**: ${result.get('quantity', 0):,.0f}",
]
if stop_loss:
content_parts.append(f"🛑 **止损**: ${price_fmt.format(stop_loss)}")
if take_profit:
content_parts.append(f"🎯 **止盈**: ${price_fmt.format(take_profit)}")
content = "\n".join(content_parts)
# 发送通知
await feishu.send_card(title, content, "green")
await telegram.send_message(f"{title}\n\n{content}")
logger.info(f"后台监控触发挂单成交: {result.get('order_id')} | {symbol}")
continue
# 处理订单平仓事件
is_win = result.get('is_win', False)
if status == 'closed_tp':
emoji = "🎯"
status_text = "止盈平仓"
color = "green"
elif status == 'closed_sl':
emoji = "🛑"
status_text = "止损平仓"
color = "red"
elif status == 'closed_be':
emoji = "🔒"
status_text = "保本止损"
color = "orange"
else:
emoji = "📤"
status_text = "平仓"
color = "blue"
win_text = "盈利" if is_win else "亏损"
win_emoji = "" if is_win else ""
side_text = "做多" if result.get('side') == 'long' else "做空"
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
title = f"{emoji} 订单{status_text} - {result.get('symbol')}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"📊 **入场**: ${result.get('entry_price', 0):,.2f}",
f"🎯 **出场**: ${result.get('exit_price', 0):,.2f}",
f"{win_emoji} **{win_text}**: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})",
f"⏱️ **持仓时间**: {result.get('hold_duration', 'N/A')}",
]
content = "\n".join(content_parts)
# 发送通知
await feishu.send_card(title, content, color)
await telegram.send_message(f"{title}\n\n{content}")
logger.info(f"后台监控触发平仓: {result.get('order_id')} | {symbol}")
except Exception as e:
logger.error(f"处理 {symbol} 价格更新失败: {e}")
# 持续监控活跃订单
while True:
try:
# 获取活跃订单
active_orders = paper_trading.get_active_orders()
if not active_orders:
await asyncio.sleep(10) # 没有活跃订单时10秒检查一次
continue
# 获取所有需要的交易对
symbols = set(order.get('symbol') for order in active_orders if order.get('symbol'))
# 获取价格并检查止盈止损
for symbol in symbols:
try:
price = bitget_service.get_current_price(symbol)
if not price:
continue
# 检查止盈止损
triggered = paper_trading.check_price_triggers(symbol, price)
# 发送通知
for result in triggered:
status = result.get('status', '')
event_type = result.get('event_type', 'order_closed')
# 处理止损移动事件
if event_type == 'stop_loss_moved':
move_type = result.get('move_type', '')
side_text = "做多" if result.get('side') == 'long' else "做空"
side_icon = '🟢' if result.get('side') == 'long' else '🔴'
pnl = result.get('current_pnl_percent', 0)
symbol_display = result.get('symbol', '')
new_stop_loss = result.get('new_stop_loss', 0)
# 根据交易对精度格式化价格
try:
from app.services.bitget_service import bitget_service
precision = bitget_service.get_precision(symbol_display)
price_fmt = f"{{:,.{precision['pricePrecision']}f}}"
except:
price_fmt = "{:,.2f}"
if move_type == 'trailing_first':
title = f"📈 移动止损已激活 - {symbol_display}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"",
f"📈 **当前盈利**: {pnl:+.2f}%",
f"🛑 **新止损价**: ${price_fmt.format(new_stop_loss)}",
f"",
f"💰 锁定利润,让利润奔跑"
]
color = "green"
elif move_type == 'trailing_update':
title = f"📊 止损已上移 - {symbol_display}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"",
f"📈 **当前盈利**: {pnl:+.2f}%",
f"🛑 **新止损价**: ${price_fmt.format(new_stop_loss)}",
f"",
f"🎯 继续锁定更多利润"
]
color = "blue"
elif move_type == 'breakeven':
title = f"📈 移动止损已启动 - {symbol_display}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"",
f"📈 **当前盈利**: {pnl:+.2f}%",
f"🛑 **新止损价**: ${price_fmt.format(new_stop_loss)}",
f"",
f"💰 锁定利润,让利润奔跑"
]
color = "green"
else:
continue
content = "\n".join(content_parts)
# 发送通知
await feishu.send_card(title, content, color)
if telegram:
message = f"{title}\n\n{content}"
await telegram.send_message(message)
logger.info(f"后台监控触发止损移动: {result.get('order_id')} | {symbol}")
continue
# 处理挂单成交事件
if event_type == 'order_filled':
side_text = "做多" if result.get('side') == 'long' else "做空"
side_icon = '🟢' if result.get('side') == 'long' else '🔴'
grade = result.get('signal_grade', 'N/A')
symbol = result.get('symbol', '')
entry_price = result.get('entry_price', 0)
filled_price = result.get('filled_price', 0)
stop_loss = result.get('stop_loss', 0)
take_profit = result.get('take_profit', 0)
# 根据交易对精度格式化价格
try:
from app.services.bitget_service import bitget_service
precision = bitget_service.get_precision(symbol)
price_fmt = f"{{:,.{precision['pricePrecision']}f}}"
except:
price_fmt = "{:,.2f}"
title = f"✅ 挂单成交 - {symbol}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"",
f"⭐ **信号等级**: {grade}",
f"",
f"💰 **挂单价**: ${price_fmt.format(entry_price)}",
f"🎯 **成交价**: ${price_fmt.format(filled_price)}",
f"📊 **持仓价值**: ${result.get('quantity', 0):,.0f}",
f"",
f"🛑 **止损价**: ${price_fmt.format(stop_loss)}",
f"🎯 **止盈价**: ${price_fmt.format(take_profit)}"
]
content = "\n".join(content_parts)
# 发送通知
await feishu.send_card(title, content, "blue")
if telegram:
message = f"{title}\n\n{content}"
await telegram.send_message(message)
logger.info(f"后台监控触发挂单成交: {result.get('order_id')} | {symbol}")
continue
# 处理订单平仓事件
is_win = result.get('is_win', False)
if status == 'closed_tp':
emoji = "🎯"
status_text = "止盈平仓"
color = "green"
elif status == 'closed_sl':
emoji = "🛑"
status_text = "止损平仓"
color = "red"
elif status == 'closed_be':
emoji = "📈"
status_text = "移动止损"
color = "orange"
else:
emoji = "📤"
status_text = "平仓"
color = "blue"
win_text = "盈利" if is_win else "亏损"
win_emoji = "" if is_win else ""
side_text = "做多" if result.get('side') == 'long' else "做空"
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
title = f"{emoji} 订单{status_text} - {result.get('symbol')}"
content_parts = [
f"{side_icon} **方向**: {side_text}",
f"📊 **入场**: ${result.get('entry_price', 0):,.2f}",
f"🎯 **出场**: ${result.get('exit_price', 0):,.2f}",
f"{win_emoji} **{win_text}**: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})",
f"⏱️ **持仓时间**: {result.get('hold_duration', 'N/A')}",
]
content = "\n".join(content_parts)
# 发送通知
await feishu.send_card(title, content, color)
await telegram.send_message(f"{title}\n\n{content}")
logger.info(f"后台监控触发平仓: {result.get('order_id')} | {symbol}")
except Exception as e:
logger.error(f"检查 {symbol} 价格失败: {e}")
# 每 3 秒检查一次
await asyncio.sleep(3)
except Exception as e:
logger.error(f"价格监控循环出错: {e}")
await asyncio.sleep(5)
async def _print_system_status():
"""打印系统状态摘要"""
from app.utils.system_status import get_system_monitor
monitor = get_system_monitor()
summary = monitor.get_summary()
logger.info("\n" + "=" * 60)
logger.info("📊 系统状态摘要")
logger.info("=" * 60)
logger.info(f"启动时间: {summary['system_start_time']}")
logger.info(f"运行时长: {int(summary['uptime_seconds'] // 60)} 分钟")
logger.info(f"Agent 总数: {summary['total_agents']}")
logger.info(f"运行中: {summary['running_agents']} | 错误: {summary['error_agents']}")
if summary['agents']:
logger.info("\n🤖 Agent 详情:")
for agent_id, agent_info in summary['agents'].items():
status_icon = {
"运行中": "",
"启动中": "🔄",
"已停止": "⏸️",
"错误": "",
"未初始化": ""
}.get(agent_info['status'], "")
logger.info(f" {status_icon} {agent_info['name']} - {agent_info['status']}")
# 显示配置
config = agent_info.get('config', {})
if config:
if 'symbols' in config:
logger.info(f" 监控: {', '.join(config['symbols'])}")
if 'paper_trading_enabled' in config:
pt_status = "已启用" if config['paper_trading_enabled'] else "未启用"
logger.info(f" 模拟交易: {pt_status}")
if 'analysis_interval' in config:
logger.info(f" 分析间隔: {config['analysis_interval']}")
logger.info("=" * 60 + "\n")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
global _price_monitor_task, _stock_agent_task, _crypto_agent_task, _news_agent_task, _astock_monitor_task
# 启动时执行
logger.info("应用启动")
# 初始化全局异常处理器
setup_global_exception_handler()
logger.info("全局异常处理器已安装")
# 初始化飞书错误通知
try:
from app.services.feishu_service import FeishuService
from app.config import get_settings
settings = get_settings()
# 使用专用的系统异常 webhook
feishu_error_service = FeishuService(
webhook_url=settings.feishu_error_webhook_url,
service_type="error"
)
init_error_notifier(
feishu_service=feishu_error_service,
enabled=True, # 启用异常通知
cooldown=300 # 5分钟冷却时间
)
logger.info("✅ 系统异常通知已启用(异常将发送到飞书)")
except Exception as e:
logger.warning(f"飞书异常通知初始化失败: {e}")
# 启动后台任务
settings = get_settings()
if getattr(settings, 'paper_trading_enabled', True):
_price_monitor_task = asyncio.create_task(price_monitor_loop())
logger.info("后台价格监控任务已创建")
# 启动加密货币智能体
if getattr(settings, 'crypto_symbols', '') and settings.crypto_symbols.strip():
try:
from app.crypto_agent.crypto_agent import get_crypto_agent
crypto_agent = get_crypto_agent()
# 防止重复启动
if not crypto_agent.running:
_crypto_agent_task = asyncio.create_task(crypto_agent.run())
logger.info(f"加密货币智能体已启动,监控: {settings.crypto_symbols}")
else:
logger.info("加密货币智能体已在运行中")
except Exception as e:
logger.error(f"加密货币智能体启动失败: {e}")
# 启动股票智能体(美股 + 港股)
us_symbols = getattr(settings, 'stock_symbols_us', '') or ''
hk_symbols = getattr(settings, 'stock_symbols_hk', '') or ''
if (us_symbols.strip() or hk_symbols.strip()):
try:
from app.stock_agent.stock_agent import get_stock_agent
stock_agent = get_stock_agent()
_stock_agent_task = asyncio.create_task(stock_agent.start())
# 设置智能体实例到 API 模块
stocks.set_stock_agent(stock_agent)
symbols_list = []
if us_symbols:
symbols_list.append(f"美股({len(us_symbols.split(','))}只)")
if hk_symbols:
symbols_list.append(f"港股({len(hk_symbols.split(','))}只)")
logger.info(f"股票智能体已启动,监控: {', '.join(symbols_list)}")
except Exception as e:
logger.error(f"股票智能体启动失败: {e}")
logger.error(f"提示: 请确保已安装 yfinance (pip install yfinance)")
else:
logger.info("股票智能体未启动(未配置股票代码)")
# 启动新闻智能体
try:
from app.news_agent.news_agent import get_news_agent
news_agent = get_news_agent()
_news_agent_task = asyncio.create_task(news_agent.start())
logger.info("新闻智能体已启动")
except Exception as e:
logger.error(f"新闻智能体启动失败: {e}")
logger.error(f"提示: 请确保已安装 feedparser 和 beautifulsoup4 (pip install feedparser beautifulsoup4)")
# 启动A股智能体
if getattr(settings, 'astock_monitor_enabled', True):
try:
from app.astock_agent import SectorMonitor
sector_monitor = SectorMonitor(
change_threshold=settings.astock_change_threshold,
top_n=settings.astock_top_n,
enable_notifier=bool(settings.dingtalk_astock_webhook)
)
# 保存实例供定时任务使用
_astock_monitor_instance = sector_monitor
logger.info(f"A股智能体已初始化")
except Exception as e:
logger.error(f"A股智能体初始化失败: {e}")
# 启动定时任务调度器
await start_scheduler()
# 显示系统状态摘要
await _print_system_status()
yield
# 关闭时执行
if _price_monitor_task:
_price_monitor_task.cancel()
try:
await _price_monitor_task
except asyncio.CancelledError:
pass
logger.info("后台价格监控任务已停止")
# 停止加密货币智能体
if _crypto_agent_task:
_crypto_agent_task.cancel()
try:
await _crypto_agent_task
except asyncio.CancelledError:
pass
logger.info("加密货币智能体已停止")
# 停止美股智能体
if _stock_agent_task:
_stock_agent_task.cancel()
try:
await _stock_agent_task
except asyncio.CancelledError:
pass
logger.info("美股智能体已停止")
# 停止新闻智能体
if _news_agent_task:
try:
from app.news_agent.news_agent import get_news_agent
news_agent = get_news_agent()
await news_agent.stop()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"停止新闻智能体失败: {e}")
logger.info("新闻智能体已停止")
# 停止A股智能体
global _astock_scheduler
if _astock_scheduler:
_astock_scheduler.shutdown(wait=False)
logger.info("A股定时任务已停止")
if _astock_monitor_task:
_astock_monitor_task.cancel()
try:
await _astock_monitor_task
except asyncio.CancelledError:
pass
logger.info("A股智能体已停止")
logger.info("应用关闭")
# 创建FastAPI应用
app = FastAPI(
title="A股AI分析Agent系统",
description="基于AI Agent的股票智能分析系统",
version="1.0.0",
lifespan=lifespan
)
# 配置CORS
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth.router, tags=["认证"])
app.include_router(admin.router, tags=["后台管理"])
app.include_router(chat.router, prefix="/api/chat", tags=["对话"])
app.include_router(stock.router, prefix="/api/stock", tags=["股票数据"])
app.include_router(skills.router, prefix="/api/skills", tags=["技能管理"])
app.include_router(llm.router, tags=["LLM模型"])
app.include_router(paper_trading.router, tags=["模拟交易"])
app.include_router(real_trading.router, tags=["实盘交易"])
app.include_router(stocks.router, prefix="/api/stocks", tags=["美股分析"])
app.include_router(signals.router, tags=["信号管理"])
app.include_router(news.router, tags=["新闻管理"])
app.include_router(system.router, prefix="/api/system", tags=["系统状态"])
# 挂载静态文件
frontend_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend")
if os.path.exists(frontend_path):
app.mount("/static", StaticFiles(directory=frontend_path), name="static")
@app.get("/")
async def root():
"""根路径,返回主应用页面"""
index_path = os.path.join(frontend_path, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"message": "页面不存在"}
@app.get("/app")
async def app_page():
"""主应用页面"""
index_path = os.path.join(frontend_path, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"message": "页面不存在"}
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "healthy"}
@app.get("/paper-trading")
async def paper_trading_page():
"""模拟交易页面"""
page_path = os.path.join(frontend_path, "paper-trading.html")
if os.path.exists(page_path):
return FileResponse(page_path)
return {"message": "页面不存在"}
@app.get("/real-trading")
async def real_trading_page():
"""实盘交易页面"""
page_path = os.path.join(frontend_path, "real-trading.html")
if os.path.exists(page_path):
return FileResponse(page_path)
return {"message": "页面不存在"}
@app.get("/signals")
async def signals_page():
"""信号列表页面"""
page_path = os.path.join(frontend_path, "signals.html")
if os.path.exists(page_path):
return FileResponse(page_path)
return {"message": "页面不存在"}
@app.get("/status")
async def status_page():
"""系统状态监控页面"""
page_path = os.path.join(frontend_path, "status.html")
if os.path.exists(page_path):
return FileResponse(page_path)
return {"message": "页面不存在"}
if __name__ == "__main__":
import uvicorn
# 设置全局异常处理器(防止主线程异常退出)
setup_global_exception_handler()
try:
uvicorn.run(
"app.main:app",
host=settings.api_host,
port=settings.api_port,
reload=settings.debug
)
except Exception as e:
logger.error(f"应用启动失败: {e}")
import traceback
logger.error(traceback.format_exc())
raise