From ceb593a061064e159e1b6c84f68b7e0b3c84e355 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 8 Feb 2026 13:51:13 +0800 Subject: [PATCH] update --- backend/app/api/paper_trading.py | 34 +++++ backend/app/main.py | 67 +++++++++- backend/app/services/paper_trading_service.py | 117 ++++++++++++++++++ frontend/paper-trading.html | 51 +++++++- 4 files changed, 264 insertions(+), 5 deletions(-) diff --git a/backend/app/api/paper_trading.py b/backend/app/api/paper_trading.py index 48f973c..b40a385 100644 --- a/backend/app/api/paper_trading.py +++ b/backend/app/api/paper_trading.py @@ -248,3 +248,37 @@ async def reset_paper_trading(): except Exception as e: logger.error(f"重置模拟交易数据失败: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/report") +async def send_report( + hours: int = Query(4, description="报告时间段(小时)"), + send_telegram: bool = Query(True, description="是否发送到Telegram") +): + """ + 手动触发发送模拟交易报告 + + - hours: 报告时间段,默认4小时 + - send_telegram: 是否发送到Telegram,默认True + """ + try: + from app.services.telegram_service import get_telegram_service + + service = get_paper_trading_service() + report = service.generate_report(hours=hours) + + result = { + "success": True, + "report": report, + "telegram_sent": False + } + + if send_telegram: + telegram = get_telegram_service() + sent = await telegram.send_message(report, parse_mode="HTML") + result["telegram_sent"] = sent + + return result + except Exception as e: + logger.error(f"生成报告失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/main.py b/backend/app/main.py index f14bc5c..a3713ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,8 +13,9 @@ from app.api import chat, stock, skills, llm, auth, admin, paper_trading import os -# 后台价格监控任务 +# 后台任务 _price_monitor_task = None +_report_task = None async def price_monitor_loop(): @@ -94,20 +95,72 @@ async def price_monitor_loop(): await asyncio.sleep(5) +async def periodic_report_loop(): + """定时报告循环 - 每4小时发送一次模拟交易报告""" + from datetime import datetime + from app.services.paper_trading_service import get_paper_trading_service + from app.services.telegram_service import get_telegram_service + + logger.info("定时报告任务已启动") + + # 计算距离下一个整4小时的等待时间 + def get_seconds_until_next_4h(): + now = datetime.now() + current_hour = now.hour + # 下一个4小时整点: 0, 4, 8, 12, 16, 20 + next_4h = ((current_hour // 4) + 1) * 4 + if next_4h >= 24: + next_4h = 0 + # 需要等到明天 + from datetime import timedelta + next_time = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + else: + next_time = now.replace(hour=next_4h, minute=0, second=0, microsecond=0) + + wait_seconds = (next_time - now).total_seconds() + return int(wait_seconds), next_time + + while True: + try: + # 计算等待时间 + wait_seconds, next_time = get_seconds_until_next_4h() + logger.info(f"下次报告时间: {next_time.strftime('%Y-%m-%d %H:%M')},等待 {wait_seconds // 3600}小时{(wait_seconds % 3600) // 60}分钟") + + # 等待到下一个4小时整点 + await asyncio.sleep(wait_seconds) + + # 生成并发送报告 + paper_trading = get_paper_trading_service() + telegram = get_telegram_service() + + report = paper_trading.generate_report(hours=4) + await telegram.send_message(report, parse_mode="HTML") + logger.info("已发送4小时模拟交易报告") + + except Exception as e: + logger.error(f"定时报告循环出错: {e}") + import traceback + logger.error(traceback.format_exc()) + await asyncio.sleep(60) # 出错后等待1分钟再重试 + + @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" - global _price_monitor_task + global _price_monitor_task, _report_task # 启动时执行 logger.info("应用启动") - # 启动后台价格监控任务 + # 启动后台任务 settings = get_settings() if getattr(settings, 'paper_trading_enabled', True): _price_monitor_task = asyncio.create_task(price_monitor_loop()) logger.info("后台价格监控任务已创建") + _report_task = asyncio.create_task(periodic_report_loop()) + logger.info("定时报告任务已创建") + yield # 关闭时执行 @@ -119,6 +172,14 @@ async def lifespan(app: FastAPI): pass logger.info("后台价格监控任务已停止") + if _report_task: + _report_task.cancel() + try: + await _report_task + except asyncio.CancelledError: + pass + logger.info("定时报告任务已停止") + logger.info("应用关闭") diff --git a/backend/app/services/paper_trading_service.py b/backend/app/services/paper_trading_service.py index 85e5274..988c8b8 100644 --- a/backend/app/services/paper_trading_service.py +++ b/backend/app/services/paper_trading_service.py @@ -554,6 +554,123 @@ class PaperTradingService: } return result + def get_period_statistics(self, hours: int = 4) -> Dict[str, Any]: + """ + 获取指定时间段内的统计数据 + + Args: + hours: 统计时间段(小时) + + Returns: + 时间段内的统计数据 + """ + db = db_service.get_session() + try: + cutoff_time = datetime.utcnow() - timedelta(hours=hours) + + # 查询时间段内平仓的订单 + closed_orders = db.query(PaperOrder).filter( + PaperOrder.status.in_([ + OrderStatus.CLOSED_TP, + OrderStatus.CLOSED_SL, + OrderStatus.CLOSED_MANUAL + ]), + PaperOrder.closed_at >= cutoff_time + ).all() + + # 查询时间段内新开仓的订单(包括当前活跃的) + new_orders = db.query(PaperOrder).filter( + PaperOrder.created_at >= cutoff_time + ).all() + + # 计算时间段内的盈亏 + period_pnl = sum(o.pnl_amount for o in closed_orders) + period_wins = len([o for o in closed_orders if o.pnl_amount > 0]) + period_losses = len([o for o in closed_orders if o.pnl_amount < 0]) + + return { + 'period_hours': hours, + 'new_orders': len(new_orders), + 'closed_orders': len(closed_orders), + 'period_pnl': round(period_pnl, 2), + 'period_wins': period_wins, + 'period_losses': period_losses, + 'period_win_rate': round(period_wins / len(closed_orders) * 100, 1) if closed_orders else 0 + } + + finally: + db.close() + + def generate_report(self, hours: int = 4) -> str: + """ + 生成模拟交易报告 + + Args: + hours: 报告时间段(小时) + + Returns: + 格式化的报告文本 + """ + # 获取总体统计 + total_stats = self.calculate_statistics() + + # 获取时间段统计 + period_stats = self.get_period_statistics(hours) + + # 获取当前活跃订单 + active_orders = self.get_active_orders() + + # 构建报告 + lines = [ + f"📊 模拟交易 {hours} 小时报告", + "", + "━━━━━━ 总体情况 ━━━━━━", + f"总交易数: {total_stats['total_trades']} | 胜率: {total_stats['win_rate']}%", + f"总盈亏: ${total_stats['total_pnl']:+.2f}", + "", + f"━━━━━━ 过去 {hours} 小时 ━━━━━━", + f"新订单: {period_stats['new_orders']} | 已平仓: {period_stats['closed_orders']}", + f"本期盈亏: ${period_stats['period_pnl']:+.2f}", + ] + + # 当前持仓 + open_orders = [o for o in active_orders if o.get('status') == 'open'] + pending_orders = [o for o in active_orders if o.get('status') == 'pending'] + + if open_orders or pending_orders: + lines.append("") + lines.append("━━━━━━ 当前订单 ━━━━━━") + + for order in open_orders[:5]: # 最多显示5个 + side_text = "做多" if order.get('side') == 'long' else "做空" + entry_price = order.get('filled_price') or order.get('entry_price', 0) + lines.append(f"✅ {order.get('symbol')} {side_text} @ ${entry_price:,.0f}") + + for order in pending_orders[:3]: # 最多显示3个挂单 + side_text = "做多" if order.get('side') == 'long' else "做空" + lines.append(f"⏳ {order.get('symbol')} {side_text} 挂单 @ ${order.get('entry_price', 0):,.0f}") + + if len(open_orders) > 5: + lines.append(f"... 还有 {len(open_orders) - 5} 个持仓") + if len(pending_orders) > 3: + lines.append(f"... 还有 {len(pending_orders) - 3} 个挂单") + + # 按等级统计 + by_grade = total_stats.get('by_grade', {}) + if by_grade: + lines.append("") + lines.append("━━━━━━ 按等级统计 ━━━━━━") + for grade in ['A', 'B', 'C']: + if grade in by_grade: + g = by_grade[grade] + pnl_sign = "+" if g['total_pnl'] >= 0 else "" + lines.append(f"{grade}级: {g['count']}笔 | 胜率{g['win_rate']}% | {pnl_sign}${g['total_pnl']:.0f}") + + lines.append("") + lines.append(f"报告时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + + return "\n".join(lines) + def reset_all_data(self) -> Dict[str, Any]: """ 重置所有模拟交易数据 diff --git a/frontend/paper-trading.html b/frontend/paper-trading.html index ee8491b..c14852c 100644 --- a/frontend/paper-trading.html +++ b/frontend/paper-trading.html @@ -14,7 +14,7 @@ .trading-container { max-width: 1400px; - min-width: 800px; + min-width: 1200px; margin: 0 auto; } @@ -62,6 +62,26 @@ color: var(--bg-primary); } + /* 发送报告按钮 */ + .report-btn { + padding: 8px 16px; + background: transparent; + border: 1px solid #1da1f2; + color: #1da1f2; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + } + + .report-btn:hover { + background: rgba(29, 161, 242, 0.1); + } + + .report-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + /* 统计卡片 */ .stats-grid { display: grid; @@ -478,6 +498,9 @@
{{ monitorRunning ? '监控中' : '未启动' }} + @@ -763,7 +786,8 @@ monitorRunning: false, latestPrices: {}, refreshInterval: null, - isFirstLoad: true + isFirstLoad: true, + sendingReport: false }; }, mounted() { @@ -827,6 +851,29 @@ } }, + async sendReport() { + this.sendingReport = true; + try { + const response = await fetch('/api/paper-trading/report?hours=4&send_telegram=true', { + method: 'POST' + }); + const data = await response.json(); + if (data.success) { + if (data.telegram_sent) { + alert('报告已发送到 Telegram Channel'); + } else { + alert('报告生成成功,但 Telegram 发送失败(可能未配置)'); + } + } else { + alert('发送失败: ' + (data.detail || '未知错误')); + } + } catch (e) { + alert('发送失败: ' + e.message); + } finally { + this.sendingReport = false; + } + }, + async fetchActiveOrders() { const response = await fetch('/api/paper-trading/orders/active'); const data = await response.json();