This commit is contained in:
aaron 2026-02-08 13:51:13 +08:00
parent 1cc9b0be45
commit ceb593a061
4 changed files with 264 additions and 5 deletions

View File

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

View File

@ -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("应用关闭")

View File

@ -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"📊 <b>模拟交易 {hours} 小时报告</b>",
"",
"━━━━━━ 总体情况 ━━━━━━",
f"总交易数: {total_stats['total_trades']} | 胜率: {total_stats['win_rate']}%",
f"总盈亏: <code>${total_stats['total_pnl']:+.2f}</code>",
"",
f"━━━━━━ 过去 {hours} 小时 ━━━━━━",
f"新订单: {period_stats['new_orders']} | 已平仓: {period_stats['closed_orders']}",
f"本期盈亏: <code>${period_stats['period_pnl']:+.2f}</code>",
]
# 当前持仓
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"<i>报告时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}</i>")
return "\n".join(lines)
def reset_all_data(self) -> Dict[str, Any]:
"""
重置所有模拟交易数据

View File

@ -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 @@
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
</div>
<button class="report-btn" @click="sendReport" :disabled="sendingReport">
{{ sendingReport ? '发送中...' : '发送报告' }}
</button>
<button class="refresh-btn" @click="manualRefresh">刷新数据</button>
<button class="reset-btn" @click="resetData">重置数据</button>
</div>
@ -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();