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 @@