update
This commit is contained in:
parent
1cc9b0be45
commit
ceb593a061
@ -248,3 +248,37 @@ async def reset_paper_trading():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"重置模拟交易数据失败: {e}")
|
logger.error(f"重置模拟交易数据失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
|
|||||||
@ -13,8 +13,9 @@ from app.api import chat, stock, skills, llm, auth, admin, paper_trading
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
# 后台价格监控任务
|
# 后台任务
|
||||||
_price_monitor_task = None
|
_price_monitor_task = None
|
||||||
|
_report_task = None
|
||||||
|
|
||||||
|
|
||||||
async def price_monitor_loop():
|
async def price_monitor_loop():
|
||||||
@ -94,20 +95,72 @@ async def price_monitor_loop():
|
|||||||
await asyncio.sleep(5)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期管理"""
|
"""应用生命周期管理"""
|
||||||
global _price_monitor_task
|
global _price_monitor_task, _report_task
|
||||||
|
|
||||||
# 启动时执行
|
# 启动时执行
|
||||||
logger.info("应用启动")
|
logger.info("应用启动")
|
||||||
|
|
||||||
# 启动后台价格监控任务
|
# 启动后台任务
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if getattr(settings, 'paper_trading_enabled', True):
|
if getattr(settings, 'paper_trading_enabled', True):
|
||||||
_price_monitor_task = asyncio.create_task(price_monitor_loop())
|
_price_monitor_task = asyncio.create_task(price_monitor_loop())
|
||||||
logger.info("后台价格监控任务已创建")
|
logger.info("后台价格监控任务已创建")
|
||||||
|
|
||||||
|
_report_task = asyncio.create_task(periodic_report_loop())
|
||||||
|
logger.info("定时报告任务已创建")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# 关闭时执行
|
# 关闭时执行
|
||||||
@ -119,6 +172,14 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
logger.info("后台价格监控任务已停止")
|
logger.info("后台价格监控任务已停止")
|
||||||
|
|
||||||
|
if _report_task:
|
||||||
|
_report_task.cancel()
|
||||||
|
try:
|
||||||
|
await _report_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("定时报告任务已停止")
|
||||||
|
|
||||||
logger.info("应用关闭")
|
logger.info("应用关闭")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -554,6 +554,123 @@ class PaperTradingService:
|
|||||||
}
|
}
|
||||||
return result
|
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]:
|
def reset_all_data(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
重置所有模拟交易数据
|
重置所有模拟交易数据
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.trading-container {
|
.trading-container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
min-width: 800px;
|
min-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +62,26 @@
|
|||||||
color: var(--bg-primary);
|
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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -478,6 +498,9 @@
|
|||||||
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
|
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
|
||||||
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
|
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="report-btn" @click="sendReport" :disabled="sendingReport">
|
||||||
|
{{ sendingReport ? '发送中...' : '发送报告' }}
|
||||||
|
</button>
|
||||||
<button class="refresh-btn" @click="manualRefresh">刷新数据</button>
|
<button class="refresh-btn" @click="manualRefresh">刷新数据</button>
|
||||||
<button class="reset-btn" @click="resetData">重置数据</button>
|
<button class="reset-btn" @click="resetData">重置数据</button>
|
||||||
</div>
|
</div>
|
||||||
@ -763,7 +786,8 @@
|
|||||||
monitorRunning: false,
|
monitorRunning: false,
|
||||||
latestPrices: {},
|
latestPrices: {},
|
||||||
refreshInterval: null,
|
refreshInterval: null,
|
||||||
isFirstLoad: true
|
isFirstLoad: true,
|
||||||
|
sendingReport: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
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() {
|
async fetchActiveOrders() {
|
||||||
const response = await fetch('/api/paper-trading/orders/active');
|
const response = await fetch('/api/paper-trading/orders/active');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user