feat: 完善 QMT 交易模块文档和配置展示功能
- 优化前端仪表盘界面 - 添加配置文件可视化展示 - 编写 QMT 模块配置文档 - 完善项目规则体系(KiloCode)
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# coding:utf-8
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
@@ -9,7 +12,7 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# 导入新的管理器类
|
||||
from qmt_engine import MultiEngineManager, TerminalStatus
|
||||
from qmt_engine import MultiEngineManager, TerminalStatus, AutoReconnectScheduler
|
||||
|
||||
|
||||
# ================= Pydantic模型 =================
|
||||
@@ -20,6 +23,8 @@ class TerminalStatusModel(BaseModel):
|
||||
alias: str
|
||||
account_id: str
|
||||
is_connected: bool
|
||||
callback_connected: bool
|
||||
physical_connected: bool
|
||||
last_heartbeat: str
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
@@ -39,17 +44,46 @@ class LogsResponse(BaseModel):
|
||||
"""日志响应模型"""
|
||||
logs: List[str]
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
"""配置响应模型"""
|
||||
reconnect_time: str
|
||||
auto_reconnect_enabled: bool
|
||||
|
||||
|
||||
class FileConfigResponse(BaseModel):
|
||||
"""配置文件响应模型"""
|
||||
redis: Dict[str, Any]
|
||||
qmt_terminals: List[Dict[str, Any]]
|
||||
strategies: Dict[str, Any]
|
||||
raw_config: str
|
||||
config_path: str
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
"""配置更新请求模型"""
|
||||
reconnect_time: Optional[str] = None
|
||||
auto_reconnect_enabled: Optional[bool] = None
|
||||
|
||||
class ReconnectResponse(BaseModel):
|
||||
"""重连响应模型"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ================= FastAPI应用 =================
|
||||
|
||||
class QMTAPIServer:
|
||||
"""多终端 QMT API服务器"""
|
||||
|
||||
def __init__(self, manager: MultiEngineManager):
|
||||
|
||||
def __init__(self, manager: MultiEngineManager, config_file: str = "config.json"):
|
||||
self.app = FastAPI(title="QMT Multi-Terminal Monitor")
|
||||
self.manager = manager
|
||||
self.config_file = config_file
|
||||
# 初始化自动重连调度器
|
||||
self.scheduler = AutoReconnectScheduler(manager, config_file=config_file)
|
||||
self._setup_middleware()
|
||||
self._setup_routes()
|
||||
# 启动调度器
|
||||
self.scheduler.start()
|
||||
|
||||
def _setup_middleware(self):
|
||||
"""设置中间件"""
|
||||
@@ -60,9 +94,56 @@ class QMTAPIServer:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
def _mask_sensitive_value(self, value: str) -> str:
|
||||
"""对敏感值进行脱敏处理"""
|
||||
if not value:
|
||||
return value
|
||||
if len(value) <= 4:
|
||||
return "*" * len(value)
|
||||
return value[0] + "*" * (len(value) - 2) + value[-1]
|
||||
|
||||
def _mask_config(self, config: dict) -> dict:
|
||||
"""对配置文件中的敏感信息进行脱敏处理"""
|
||||
masked = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict):
|
||||
masked[key] = self._mask_config(value)
|
||||
elif isinstance(value, list):
|
||||
masked[key] = [
|
||||
self._mask_config(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
elif key in ("password", "pwd"):
|
||||
masked[key] = "******"
|
||||
elif key == "account_id" and isinstance(value, str):
|
||||
masked[key] = self._mask_sensitive_value(value)
|
||||
elif key == "path" and isinstance(value, str):
|
||||
# 路径脱敏,保留目录结构但隐藏用户名
|
||||
parts = value.split(os.sep)
|
||||
if len(parts) >= 3:
|
||||
masked[key] = os.sep.join(parts[:-2] + ["****"] + parts[-2:])
|
||||
else:
|
||||
masked[key] = value
|
||||
else:
|
||||
masked[key] = value
|
||||
return masked
|
||||
|
||||
def _find_config_file(self) -> Optional[str]:
|
||||
"""查找配置文件路径"""
|
||||
# 按优先级查找配置文件
|
||||
config_paths = [
|
||||
"config.json",
|
||||
"qmt/config.json",
|
||||
os.environ.get("QMT_CONFIG_PATH", "")
|
||||
]
|
||||
for path in config_paths:
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
def _setup_routes(self):
|
||||
"""设置路由"""
|
||||
|
||||
|
||||
@self.app.get("/", summary="仪表盘页面")
|
||||
async def read_root():
|
||||
"""返回仪表盘HTML页面"""
|
||||
@@ -72,7 +153,7 @@ class QMTAPIServer:
|
||||
|
||||
@self.app.get("/api/status", response_model=StatusResponse, summary="获取所有终端状态")
|
||||
def get_status():
|
||||
"""获取所有 QMT 终端的连接状态"""
|
||||
"""获取所有 QMT 终端的连接状态,包含物理连接验证"""
|
||||
terminal_data = self.manager.get_all_status()
|
||||
|
||||
terminals = [
|
||||
@@ -81,6 +162,8 @@ class QMTAPIServer:
|
||||
alias=t.alias,
|
||||
account_id=t.account_id,
|
||||
is_connected=t.is_connected,
|
||||
callback_connected=t.callback_connected,
|
||||
physical_connected=t.physical_connected,
|
||||
last_heartbeat=t.last_heartbeat
|
||||
) for t in terminal_data
|
||||
]
|
||||
@@ -96,13 +179,22 @@ class QMTAPIServer:
|
||||
"""汇总所有终端的实盘持仓和所有策略的虚拟持仓"""
|
||||
real_pos_data = {}
|
||||
virtual_pos_data = {}
|
||||
|
||||
# 调试日志:记录管理器状态
|
||||
import logging
|
||||
logger = logging.getLogger("QMT_API")
|
||||
logger.info(f"[POS DEBUG] manager.units 数量: {len(self.manager.units) if hasattr(self.manager, 'units') else 'N/A'}")
|
||||
logger.info(f"[POS DEBUG] manager.config strategies: {list(self.manager.config.get('strategies', {}).keys()) if hasattr(self.manager, 'config') else 'N/A'}")
|
||||
logger.info(f"[POS DEBUG] pos_manager 是否存在: {hasattr(self.manager, 'pos_manager') and self.manager.pos_manager is not None}")
|
||||
|
||||
# 1. 遍历所有终端单元获取实盘持仓
|
||||
for qmt_id, unit in self.manager.units.items():
|
||||
positions = []
|
||||
logger.info(f"[POS DEBUG] 处理终端: {qmt_id}, callback: {unit.callback}, callback.is_connected: {unit.callback.is_connected if unit.callback else 'N/A'}")
|
||||
if unit.callback and unit.callback.is_connected:
|
||||
try:
|
||||
xt_pos = unit.xt_trader.query_stock_positions(unit.acc_obj)
|
||||
logger.info(f"[POS DEBUG] 终端 {qmt_id} 查询到持仓数量: {len(xt_pos) if xt_pos else 0}")
|
||||
if xt_pos:
|
||||
positions = [
|
||||
{
|
||||
@@ -112,14 +204,27 @@ class QMTAPIServer:
|
||||
"market_value": round(p.market_value, 2)
|
||||
} for p in xt_pos if p.volume > 0
|
||||
]
|
||||
except:
|
||||
pass
|
||||
logger.info(f"[POS DEBUG] 终端 {qmt_id} 有效持仓(volume>0): {len(positions)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[POS DEBUG] 终端 {qmt_id} 查询持仓失败: {e}")
|
||||
real_pos_data[qmt_id] = positions
|
||||
|
||||
# 2. 遍历所有策略获取虚拟持仓
|
||||
for s_name in self.manager.config.get('strategies', {}).keys():
|
||||
v_data = self.manager.pos_manager.get_all_virtual_positions(s_name)
|
||||
virtual_pos_data[s_name] = v_data
|
||||
try:
|
||||
if hasattr(self.manager, 'pos_manager') and self.manager.pos_manager is not None:
|
||||
v_data = self.manager.pos_manager.get_all_virtual_positions(s_name)
|
||||
logger.info(f"[POS DEBUG] 策略 {s_name} 虚拟持仓: {v_data}")
|
||||
virtual_pos_data[s_name] = v_data
|
||||
else:
|
||||
logger.warning(f"[POS DEBUG] pos_manager 未初始化,策略 {s_name} 无法获取虚拟持仓")
|
||||
virtual_pos_data[s_name] = {}
|
||||
except Exception as e:
|
||||
logger.error(f"[POS DEBUG] 获取策略 {s_name} 虚拟持仓失败: {e}")
|
||||
virtual_pos_data[s_name] = {}
|
||||
|
||||
logger.info(f"[POS DEBUG] 最终 real_positions keys: {list(real_pos_data.keys())}, 总持仓数: {sum(len(v) for v in real_pos_data.values())}")
|
||||
logger.info(f"[POS DEBUG] 最终 virtual_positions keys: {list(virtual_pos_data.keys())}")
|
||||
|
||||
return PositionsResponse(
|
||||
real_positions=real_pos_data,
|
||||
@@ -137,11 +242,89 @@ class QMTAPIServer:
|
||||
"""健康检查:只要有一个终端在线即视为正常"""
|
||||
terminal_data = self.manager.get_all_status()
|
||||
any_connected = any(t.is_connected for t in terminal_data)
|
||||
|
||||
|
||||
if self.manager.is_running and any_connected:
|
||||
return {"status": "healthy", "timestamp": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
else:
|
||||
return {"status": "unhealthy", "reason": "No terminals connected", "timestamp": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
@self.app.get("/api/config", response_model=ConfigResponse, summary="获取自动重连配置")
|
||||
def get_config():
|
||||
"""获取当前自动重连配置"""
|
||||
config = self.scheduler.get_config()
|
||||
return ConfigResponse(
|
||||
reconnect_time=config["reconnect_time"],
|
||||
auto_reconnect_enabled=config["enabled"]
|
||||
)
|
||||
|
||||
@self.app.post("/api/config", summary="更新自动重连配置")
|
||||
def update_config(request: ConfigUpdateRequest):
|
||||
"""更新自动重连配置"""
|
||||
success = True
|
||||
message = "配置更新成功"
|
||||
|
||||
if request.reconnect_time is not None:
|
||||
if not self.scheduler.set_reconnect_time(request.reconnect_time):
|
||||
success = False
|
||||
message = "时间格式错误,请使用 HH:MM 格式"
|
||||
|
||||
if request.auto_reconnect_enabled is not None:
|
||||
self.scheduler.set_enabled(request.auto_reconnect_enabled)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": message, "config": self.scheduler.get_config()}
|
||||
else:
|
||||
return {"success": False, "message": message}
|
||||
|
||||
@self.app.post("/api/reconnect", response_model=ReconnectResponse, summary="手动触发重连")
|
||||
def trigger_reconnect():
|
||||
"""手动触发立即重连所有终端"""
|
||||
self.scheduler.trigger_reconnect()
|
||||
return ReconnectResponse(
|
||||
success=True,
|
||||
message="重连任务已在后台启动"
|
||||
)
|
||||
|
||||
@self.app.get("/api/file_config", response_model=FileConfigResponse, summary="获取配置文件内容")
|
||||
def get_file_config():
|
||||
"""获取配置文件内容,敏感信息已脱敏"""
|
||||
config_path = self._find_config_file()
|
||||
|
||||
if not config_path or not os.path.exists(config_path):
|
||||
return FileConfigResponse(
|
||||
redis={},
|
||||
qmt_terminals=[],
|
||||
strategies={},
|
||||
raw_config="",
|
||||
config_path=""
|
||||
)
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 脱敏处理
|
||||
masked_config = self._mask_config(config)
|
||||
|
||||
# 获取原始 JSON 字符串
|
||||
raw_json = json.dumps(config, ensure_ascii=False, indent=4)
|
||||
|
||||
return FileConfigResponse(
|
||||
redis=masked_config.get("redis", {}),
|
||||
qmt_terminals=masked_config.get("qmt_terminals", []),
|
||||
strategies=masked_config.get("strategies", {}),
|
||||
raw_config=raw_json,
|
||||
config_path=os.path.abspath(config_path)
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"读取配置文件失败: {e}")
|
||||
return FileConfigResponse(
|
||||
redis={},
|
||||
qmt_terminals=[],
|
||||
strategies={},
|
||||
raw_config=f"读取配置文件失败: {str(e)}",
|
||||
config_path=config_path
|
||||
)
|
||||
|
||||
def get_app(self) -> FastAPI:
|
||||
"""获取FastAPI应用实例"""
|
||||
@@ -150,7 +333,12 @@ class QMTAPIServer:
|
||||
|
||||
# ================= 辅助函数 =================
|
||||
|
||||
def create_api_server(manager: MultiEngineManager) -> FastAPI:
|
||||
"""创建API服务器入口"""
|
||||
server = QMTAPIServer(manager)
|
||||
def create_api_server(manager: MultiEngineManager, config_file: str = "config.json") -> FastAPI:
|
||||
"""创建API服务器入口
|
||||
|
||||
参数:
|
||||
- manager: MultiEngineManager 实例
|
||||
- config_file: 配置文件路径
|
||||
"""
|
||||
server = QMTAPIServer(manager, config_file=config_file)
|
||||
return server.get_app()
|
||||
@@ -5,233 +5,374 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QMT 多终端监控看板</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
|
||||
<script src="https://unpkg.com/naive-ui"></script>
|
||||
<style>
|
||||
body { background-color: #f0f2f5; margin: 0; padding: 20px; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', sans-serif; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.box-card { margin-bottom: 20px; }
|
||||
.log-box {
|
||||
background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px;
|
||||
height: 350px; overflow-y: scroll; font-family: 'Consolas', monospace;
|
||||
font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.log-line { margin: 0; border-bottom: 1px solid #333; white-space: pre-wrap; word-break: break-all; }
|
||||
.terminal-group { margin-bottom: 15px; border: 1px solid #ebeef5; border-radius: 8px; padding: 10px; background: #fff; }
|
||||
.terminal-title { font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #409EFF; display: flex; align-items: center; }
|
||||
.status-badge { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; }
|
||||
.bg-green { background-color: #67C23A; }
|
||||
.bg-red { background-color: #F56C6C; }
|
||||
.bg-gray { background-color: #909399; }
|
||||
.virtual-item { margin-bottom: 15px; border-left: 4px solid #E6A23C; padding-left: 10px; }
|
||||
body { margin: 0; min-height: 100vh; background: #f5f7fa; }
|
||||
.app-container { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
||||
.log-box { height: 300px; overflow-y: auto; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px; }
|
||||
.log-line { padding: 4px 8px; margin: 2px 0; border-radius: 4px; white-space: pre-wrap; }
|
||||
.log-error { color: #cf1322; background: rgba(207, 34, 34, 0.08); border-left: 3px solid #cf1322; }
|
||||
.log-warning { color: #d46b08; background: rgba(212, 107, 8, 0.08); border-left: 3px solid #d46b08; }
|
||||
.log-success { color: #389e0d; background: rgba(56, 158, 13, 0.08); border-left: 3px solid #389e0d; }
|
||||
.log-info { color: #096dd9; background: rgba(9, 109, 217, 0.08); border-left: 3px solid #096dd9; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 8px; }
|
||||
.status-connected { background: #52c41a; box-shadow: 0 0 8px rgba(82, 196, 26, 0.4); }
|
||||
.status-disconnected { background: #ff4d4f; }
|
||||
.status-connecting { background: #faad14; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.footer { text-align: center; padding: 20px; color: #8c8c8c; font-size: 12px; }
|
||||
.config-section { margin-bottom: 16px; padding: 16px; background: #fafafa; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.config-section-title { font-size: 14px; font-weight: 600; color: #096dd9; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e8e8e8; }
|
||||
.config-item { display: flex; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
|
||||
.config-item:last-child { border-bottom: none; }
|
||||
.config-key { color: #666; width: 140px; flex-shrink: 0; }
|
||||
.config-value { color: #333; flex: 1; word-break: break-all; }
|
||||
.config-json { background: #fafafa; padding: 12px; border-radius: 6px; border: 1px solid #e8e8e8; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; line-height: 1.6; max-height: 400px; overflow-y: auto; white-space: pre-wrap; color: #333; }
|
||||
.terminal-badge { display: inline-block; padding: 2px 8px; background: #e6f7ff; color: #096dd9; border: 1px solid #91d5ff; border-radius: 4px; font-size: 12px; margin-right: 8px; }
|
||||
.strategy-badge { display: inline-block; padding: 2px 8px; background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; border-radius: 4px; font-size: 12px; margin-right: 8px; }
|
||||
.stat-card-green { background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%); color: #389e0d; border: 1px solid #b7eb8f; }
|
||||
.stat-card-blue { background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%); color: #096dd9; border: 1px solid #91d5ff; }
|
||||
.stat-card-purple { background: linear-gradient(135deg, #f9f0ff 0%, #efdbff 100%); color: #722ed1; border: 1px solid #d3adf7; }
|
||||
.stat-card-orange { background: linear-gradient(135deg, #fff7e6 0%, #ffd591 100%); color: #d46b08; border: 1px solid #ffe7ba; }
|
||||
.title-bar { background: #fff; padding: 16px 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); display: flex; justify-content: space-between; align-items: center; }
|
||||
.title-text { font-size: 20px; font-weight: 600; color: #1a1a1a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<el-container>
|
||||
<el-header height="auto" style="padding: 0;">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div style="display:flex; align-items:center;">
|
||||
<el-icon size="24" style="margin-right: 10px;"><Monitor /></el-icon>
|
||||
<span style="font-weight: bold; font-size: 20px;">QMT 多账号实盘守护系统</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-tag :type="status.running ? 'success' : 'info'" effect="dark" style="margin-right: 10px;">
|
||||
系统: {{ status.running ? '运行中' : '离线' }}
|
||||
</el-tag>
|
||||
<el-button type="primary" :icon="Refresh" @click="manualRefresh" :loading="loading">手动刷新</el-button>
|
||||
<div id="app" class="app-container">
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<n-message-provider>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<!-- 顶部状态栏 -->
|
||||
<div class="title-bar">
|
||||
<span class="title-text">QMT 多账号实盘守护系统</span>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<n-tag :type="connectionStatusType">{{ connectionStatusText }}</n-tag>
|
||||
<n-button type="info" @click="showConfigModal = true" size="small">查看配置</n-button>
|
||||
<n-button type="warning" @click="manualReconnect" :loading="reconnecting" size="small">重连</n-button>
|
||||
<n-button type="primary" @click="manualRefresh" :loading="loading" size="small">刷新</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 终端状态概览 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="t in status.terminals" :key="t.qmt_id">
|
||||
<el-card shadow="never" style="background: #fcfcfc;">
|
||||
<div style="font-size: 14px; font-weight: bold; margin-bottom: 5px;">{{ t.alias }}</div>
|
||||
<div style="font-size: 12px; color: #909399;">ID: {{ t.account_id }}</div>
|
||||
<div style="margin-top: 8px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<el-tag :type="t.is_connected ? 'success' : 'danger'" size="small">
|
||||
{{ t.is_connected ? '已连接' : '已断开' }}
|
||||
</el-tag>
|
||||
<span style="font-size: 11px; color: #c0c4cc;">{{ t.last_heartbeat }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div style="padding: 10px;">
|
||||
<div style="font-size: 12px; color: #909399;">启动时间: {{ status.start_time }}</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<el-checkbox v-model="autoRefresh" label="自动刷新 (1min)" border size="small"></el-checkbox>
|
||||
<span style="font-size: 12px; margin-left: 8px; color: #E6A23C;">{{ tradingStatusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</el-header>
|
||||
<n-card style="margin-bottom: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-gi v-for="t in status.terminals" :key="t.qmt_id">
|
||||
<n-card size="small" :bordered="false">
|
||||
<n-descriptions :column="1" size="small">
|
||||
<n-descriptions-item :label="t.alias">
|
||||
<span :class="['status-dot', t.is_connected ? 'status-connected' : 'status-disconnected']"></span>
|
||||
{{ t.is_connected ? '已连接' : '已断开' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="账户">{{ t.account_id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="心跳">{{ t.last_heartbeat }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<el-main style="padding: 0;">
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:多账号实盘持仓 -->
|
||||
<el-col :span="13">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><el-icon><Suitcase /></el-icon> 实盘真实持仓 (按终端分组)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(positions.real_positions).length === 0" style="text-align:center; padding:20px; color:#909399;">暂无持仓数据</div>
|
||||
|
||||
<div v-for="(posList, qmtId) in positions.real_positions" :key="qmtId" class="terminal-group">
|
||||
<div class="terminal-title">
|
||||
<span class="status-badge" :class="getTerminalStatusClass(qmtId)"></span>
|
||||
{{ getTerminalAlias(qmtId) }}
|
||||
</div>
|
||||
<el-table :data="posList" style="width: 100%" border size="small" empty-text="当前空仓">
|
||||
<el-table-column prop="code" label="代码" width="100"></el-table-column>
|
||||
<el-table-column prop="volume" label="持仓" width="90"></el-table-column>
|
||||
<el-table-column prop="can_use" label="可用" width="90"></el-table-column>
|
||||
<el-table-column prop="market_value" label="市值"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- 持仓汇总 -->
|
||||
<n-grid :cols="4" :x-gap="12" style="margin-bottom: 20px;">
|
||||
<n-gi>
|
||||
<n-card size="small" class="stat-card-green" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<n-statistic label="持仓品种" :value="totalPositions" value-style="color: #389e0d; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card size="small" class="stat-card-blue" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<n-statistic label="总持仓" :value="formatNumber(totalVolume)" value-style="color: #096dd9; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card size="small" class="stat-card-purple" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<n-statistic label="总市值" :value="'¥' + formatNumber(totalMarketValue)" value-style="color: #722ed1; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card size="small" class="stat-card-orange" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<n-statistic label="策略数" :value="Object.keys(positions.virtual_positions).length" value-style="color: #d46b08; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 右侧:Redis 虚拟账本 -->
|
||||
<el-col :span="11">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><el-icon><Memo /></el-icon> Redis 虚拟账本 (策略隔离)</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" class="virtual-item">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:5px;">
|
||||
<span style="font-weight:bold; font-size:13px; color:#606266;">{{ strategyName }}</span>
|
||||
<el-tag size="small" type="warning">虚拟占位: {{ Object.keys(posObj).length }}</el-tag>
|
||||
</div>
|
||||
<el-table :data="formatVirtual(posObj)" style="width: 100%;" border size="small">
|
||||
<el-table-column prop="code" label="股票代码"></el-table-column>
|
||||
<el-table-column prop="vol" label="记账数量"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<!-- 主体区域 -->
|
||||
<n-grid :cols="2" :x-gap="20">
|
||||
<!-- 真实持仓 -->
|
||||
<n-gi>
|
||||
<n-card title="实盘真实持仓 (按终端分组)" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<template #header-extra>
|
||||
<n-tag type="info">{{ Object.keys(positions.real_positions).length }} 终端</n-tag>
|
||||
</template>
|
||||
<div v-for="(posList, qmtId) in positions.real_positions" :key="qmtId" style="margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
||||
<span :class="['status-dot', getTerminalStatusClass(qmtId)]"></span>
|
||||
<strong>{{ getTerminalAlias(qmtId) }}</strong>
|
||||
<n-tag size="small" style="margin-left: 8px;">{{ posList.length }} 只</n-tag>
|
||||
</div>
|
||||
<n-data-table :columns="realPosColumns" :data="posList" size="small" :bordered="false" :single-line="false" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<!-- 底部:日志 -->
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header"><span>系统实时日志 (最新 50 条)</span></div>
|
||||
<!-- 虚拟账本 -->
|
||||
<n-gi>
|
||||
<n-card title="Redis 虚拟账本 (策略隔离)" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<template #header-extra>
|
||||
<n-tag type="warning">{{ Object.keys(positions.virtual_positions).length }} 策略</n-tag>
|
||||
</template>
|
||||
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" style="margin-bottom: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<strong>{{ strategyName }}</strong>
|
||||
<n-tag size="small" type="warning">{{ Object.keys(posObj).length }} 只</n-tag>
|
||||
</div>
|
||||
<n-data-table :columns="virtualPosColumns" :data="formatVirtual(posObj)" size="small" :bordered="false" :single-line="false" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 日志 -->
|
||||
<n-card title="系统日志" style="margin-top: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
|
||||
<template #header-extra>
|
||||
<span style="color: #8c8c8c;">{{ logs.length }} 条</span>
|
||||
</template>
|
||||
<div class="log-box" ref="logBox">
|
||||
<div v-for="(line, index) in logs" :key="index" class="log-line">{{ line }}</div>
|
||||
<div v-for="(line, index) in logs" :key="index" class="log-line" :class="getLogClass(line)">
|
||||
{{ formatLogTime(line) }} {{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</n-card>
|
||||
|
||||
<div class="footer">最后更新: {{ lastUpdateTime }}</div>
|
||||
|
||||
<!-- 配置查看模态框 -->
|
||||
<n-modal v-model:show="showConfigModal" preset="card" title="配置文件内容" style="width: 700px; max-width: 90vw;" :mask-closable="false" :bordered="false">
|
||||
<template #header-extra>
|
||||
<n-button type="primary" size="small" @click="copyConfig">复制配置</n-button>
|
||||
</template>
|
||||
<n-tabs type="card" animated>
|
||||
<n-tab-pane name="redis" tab="Redis 配置">
|
||||
<div class="config-section" v-if="fileConfig.redis && Object.keys(fileConfig.redis).length > 0">
|
||||
<div v-for="(value, key) in fileConfig.redis" :key="key" class="config-item">
|
||||
<span class="config-key">{{ key }}:</span>
|
||||
<span class="config-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无 Redis 配置" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="terminals" tab="QMT 终端">
|
||||
<div v-for="(terminal, idx) in fileConfig.qmt_terminals" :key="idx" class="config-section">
|
||||
<div class="config-section-title">
|
||||
<span class="terminal-badge">终端 {{ idx + 1 }}</span>
|
||||
{{ terminal.alias || terminal.qmt_id }}
|
||||
</div>
|
||||
<div v-for="(value, key) in terminal" :key="key" class="config-item">
|
||||
<span class="config-key">{{ key }}:</span>
|
||||
<span class="config-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-if="!fileConfig.qmt_terminals || fileConfig.qmt_terminals.length === 0" description="暂无终端配置" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="strategies" tab="策略配置">
|
||||
<div v-for="(strategy, name) in fileConfig.strategies" :key="name" class="config-section">
|
||||
<div class="config-section-title">
|
||||
<span class="strategy-badge">{{ name }}</span>
|
||||
终端: {{ strategy.qmt_id }}
|
||||
</div>
|
||||
<div v-for="(value, key) in strategy" :key="key" class="config-item">
|
||||
<template v-if="key !== 'execution'">
|
||||
<span class="config-key">{{ key }}:</span>
|
||||
<span class="config-value">{{ value }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="margin-left: 140px; margin-top: 8px;">
|
||||
<div style="color: #8c8c8c; font-size: 12px; margin-bottom: 4px;">execution:</div>
|
||||
<div v-for="(execVal, execKey) in value" :key="execKey" class="config-item" style="margin-left: 20px;">
|
||||
<span class="config-key">{{ execKey }}:</span>
|
||||
<span class="config-value">{{ execVal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-if="!fileConfig.strategies || Object.keys(fileConfig.strategies).length === 0" description="暂无策略配置" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="json" tab="JSON 源码">
|
||||
<div class="config-json">{{ fileConfig.raw_config || '暂无配置内容' }}</div>
|
||||
<div v-if="fileConfig.config_path" style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
|
||||
配置文件路径: {{ fileConfig.config_path }}
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
|
||||
const { Monitor, Refresh, Suitcase, Memo } = ElementPlusIconsVue;
|
||||
const { createApp, ref, onMounted, onUnmounted, computed, watch } = Vue;
|
||||
const { NCard, NButton, NTag, NGrid, NGi, NDescriptions, NDescriptionsItem, NDataTable, NStatistic, NConfigProvider, NMessageProvider, NNotificationProvider, NDialogProvider, NModal, NTabs, NTabPane, NEmpty } = naive;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const status = ref({ running: false, terminals: [], start_time: "" });
|
||||
const status = ref({ terminals: [] });
|
||||
const positions = ref({ real_positions: {}, virtual_positions: {} });
|
||||
const logs = ref([]);
|
||||
const autoRefresh = ref(true);
|
||||
const loading = ref(false);
|
||||
const reconnecting = ref(false);
|
||||
const logBox = ref(null);
|
||||
let timer = null;
|
||||
const lastUpdateTime = ref("");
|
||||
const API_BASE = "";
|
||||
|
||||
const API_BASE = "";
|
||||
// 配置相关
|
||||
const showConfigModal = ref(false);
|
||||
const fileConfig = ref({ redis: {}, qmt_terminals: [], strategies: {}, raw_config: "", config_path: "" });
|
||||
const configLoading = ref(false);
|
||||
|
||||
const isTradingTime = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
if (day === 0 || day === 6) return false;
|
||||
const val = now.getHours() * 100 + now.getMinutes();
|
||||
return (val >= 900 && val <= 1515);
|
||||
};
|
||||
// 表格列(使用简单 render,不依赖 h 函数)
|
||||
const realPosColumns = [
|
||||
{ title: '代码', key: 'code', ellipsis: { tooltip: true } },
|
||||
{ title: '持仓', key: 'volume', render: (row) => row.volume.toLocaleString() },
|
||||
{ title: '可用', key: 'can_use', render: (row) => row.can_use.toLocaleString() },
|
||||
{ title: '市值', key: 'market_value', render: (row) => '¥' + row.market_value.toLocaleString() }
|
||||
];
|
||||
|
||||
const tradingStatusText = computed(() => {
|
||||
return isTradingTime() ? "市场开放中" : "非交易时段";
|
||||
const virtualPosColumns = [
|
||||
{ title: '代码', key: 'code', ellipsis: { tooltip: true } },
|
||||
{ title: '数量', key: 'vol', render: (row) => parseInt(row.vol).toLocaleString() }
|
||||
];
|
||||
|
||||
// 状态
|
||||
const connectionStatusText = computed(() => {
|
||||
if (status.value.terminals.length === 0) return '未连接';
|
||||
return status.value.terminals.every(t => t.is_connected) ? '全部在线' : '部分离线';
|
||||
});
|
||||
|
||||
const connectionStatusType = computed(() => {
|
||||
if (status.value.terminals.length === 0) return 'error';
|
||||
return status.value.terminals.every(t => t.is_connected) ? 'success' : 'warning';
|
||||
});
|
||||
|
||||
const getTerminalStatusClass = (qmtId) => {
|
||||
const t = status.value.terminals.find(x => x.qmt_id === qmtId);
|
||||
return t?.is_connected ? 'status-connected' : 'status-disconnected';
|
||||
};
|
||||
|
||||
const getTerminalAlias = (qmtId) => status.value.terminals.find(x => x.qmt_id === qmtId)?.alias || qmtId;
|
||||
|
||||
// 统计
|
||||
const totalPositions = computed(() => Object.values(positions.value.real_positions).reduce((sum, list) => sum + list.length, 0));
|
||||
const totalVolume = computed(() => Object.values(positions.value.real_positions).flat().reduce((sum, p) => sum + (p.volume || 0), 0));
|
||||
const totalMarketValue = computed(() => Object.values(positions.value.real_positions).flat().reduce((sum, p) => sum + (p.market_value || 0), 0));
|
||||
|
||||
// 工具
|
||||
const formatNumber = (num) => {
|
||||
if (num === undefined || num === null || isNaN(num)) return '0';
|
||||
return num >= 10000 ? (num / 10000).toFixed(2) + '万' : num.toLocaleString();
|
||||
};
|
||||
const formatVirtual = (obj) => Object.keys(obj).map(k => ({ code: k, vol: parseInt(obj[k]) || 0 }));
|
||||
const getLogClass = (line) => {
|
||||
if (line.includes('ERROR') || line.includes('错误')) return 'log-error';
|
||||
if (line.includes('WARNING') || line.includes('警告')) return 'log-warning';
|
||||
if (line.includes('SUCCESS') || line.includes('成功')) return 'log-success';
|
||||
return 'log-info';
|
||||
};
|
||||
const formatLogTime = (line) => {
|
||||
const match = line.match(/\[(.*?)\]/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
// 数据获取
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const sRes = await fetch(`${API_BASE}/api/status`);
|
||||
if(sRes.ok) status.value = await sRes.json();
|
||||
|
||||
const pRes = await fetch(`${API_BASE}/api/positions`);
|
||||
if(pRes.ok) positions.value = await pRes.json();
|
||||
|
||||
const lRes = await fetch(`${API_BASE}/api/logs`);
|
||||
if(lRes.ok) {
|
||||
const data = await lRes.json();
|
||||
logs.value = data.logs;
|
||||
setTimeout(() => {
|
||||
if(logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch Error:", e);
|
||||
console.log('[DEBUG] 获取数据...');
|
||||
const [s, p, l] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/status`).then(r => r.ok ? r.json() : {}),
|
||||
fetch(`${API_BASE}/api/positions`).then(r => r.ok ? r.json() : {}),
|
||||
fetch(`${API_BASE}/api/logs`).then(r => r.ok ? r.json() : {})
|
||||
]);
|
||||
status.value = s;
|
||||
positions.value = p;
|
||||
logs.value = l.logs || [];
|
||||
if (logBox.value) setTimeout(() => logBox.value.scrollTop = logBox.value.scrollHeight, 100);
|
||||
lastUpdateTime.value = new Date().toLocaleString('zh-CN');
|
||||
console.log('[DEBUG] 数据已更新', p);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTerminalAlias = (qmtId) => {
|
||||
const t = status.value.terminals.find(x => x.qmt_id === qmtId);
|
||||
return t ? t.alias : qmtId;
|
||||
};
|
||||
|
||||
const getTerminalStatusClass = (qmtId) => {
|
||||
const t = status.value.terminals.find(x => x.qmt_id === qmtId);
|
||||
return t && t.is_connected ? 'bg-green' : 'bg-red';
|
||||
};
|
||||
|
||||
const formatVirtual = (obj) => {
|
||||
return Object.keys(obj).map(k => ({ code: k, vol: obj[k] }));
|
||||
};
|
||||
|
||||
const manualRefresh = () => fetchData();
|
||||
const manualReconnect = async () => {
|
||||
reconnecting.value = true;
|
||||
await fetch(`${API_BASE}/api/reconnect`, { method: 'POST' });
|
||||
setTimeout(fetchData, 2000);
|
||||
setTimeout(() => reconnecting.value = false, 5000);
|
||||
};
|
||||
|
||||
// 获取配置文件内容
|
||||
const fetchConfig = async () => {
|
||||
configLoading.value = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/file_config`).then(r => r.ok ? r.json() : {});
|
||||
fileConfig.value = res;
|
||||
} catch (e) {
|
||||
console.error('[CONFIG] 获取配置失败:', e);
|
||||
} finally {
|
||||
configLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 复制配置到剪贴板
|
||||
const copyConfig = () => {
|
||||
if (fileConfig.value.raw_config) {
|
||||
navigator.clipboard.writeText(fileConfig.value.raw_config).then(() => {
|
||||
// 使用 Naive UI 的 message
|
||||
if (typeof message !== 'undefined') {
|
||||
message.success('配置已复制到剪贴板');
|
||||
} else {
|
||||
alert('配置已复制到剪贴板');
|
||||
}
|
||||
}).catch(() => {
|
||||
if (typeof message !== 'undefined') {
|
||||
message.error('复制失败');
|
||||
} else {
|
||||
alert('复制失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
timer = setInterval(() => {
|
||||
if (autoRefresh.value && isTradingTime()) fetchData();
|
||||
}, 60000);
|
||||
let timer = null;
|
||||
const unwatch = watch(showConfigModal, (newVal) => {
|
||||
if (newVal && !fileConfig.value.raw_config) {
|
||||
fetchConfig();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer); });
|
||||
|
||||
onMounted(() => { fetchData(); timer = setInterval(fetchData, 30000); });
|
||||
onUnmounted(() => { if (timer) clearInterval(timer); unwatch(); });
|
||||
|
||||
return {
|
||||
status, positions, logs, autoRefresh, loading, logBox,
|
||||
manualRefresh, formatVirtual, tradingStatusText,
|
||||
status, positions, logs, loading, reconnecting, logBox, lastUpdateTime,
|
||||
connectionStatusText, connectionStatusType,
|
||||
manualRefresh, manualReconnect,
|
||||
getTerminalAlias, getTerminalStatusClass,
|
||||
Monitor, Refresh, Suitcase, Memo
|
||||
formatNumber, formatVirtual, getLogClass, formatLogTime,
|
||||
totalPositions, totalVolume, totalMarketValue,
|
||||
realPosColumns, virtualPosColumns,
|
||||
// 配置相关
|
||||
showConfigModal, fileConfig, configLoading,
|
||||
fetchConfig, copyConfig
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
app.use(ElementPlus);
|
||||
app.use(naive);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,7 @@ import threading
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
from dateutil.parser import parse as parse_time
|
||||
|
||||
import redis
|
||||
from xtquant import xtdata
|
||||
@@ -31,6 +32,8 @@ class TerminalStatus:
|
||||
alias: str
|
||||
account_id: str
|
||||
is_connected: bool
|
||||
callback_connected: bool
|
||||
physical_connected: bool
|
||||
last_heartbeat: str
|
||||
|
||||
# ================= 1. 业务逻辑辅助类 =================
|
||||
@@ -101,6 +104,201 @@ class DailySettlement:
|
||||
def reset_flag(self):
|
||||
self.has_settled = False
|
||||
|
||||
|
||||
# ================= 1.5 定时重连调度器 =================
|
||||
|
||||
class AutoReconnectScheduler:
|
||||
"""每日定时自动重连调度器"""
|
||||
|
||||
def __init__(self, manager, reconnect_time="22:00", config_file="config.json"):
|
||||
"""
|
||||
初始化定时重连调度器。
|
||||
|
||||
参数:
|
||||
- manager: MultiEngineManager 实例
|
||||
- reconnect_time: 重连时间(格式 "HH:MM"),默认 22:00
|
||||
- config_file: 配置文件路径
|
||||
"""
|
||||
self.manager = manager
|
||||
self.logger = logging.getLogger("QMT_Engine")
|
||||
self.reconnect_time = reconnect_time
|
||||
self.config_file = config_file
|
||||
self.scheduler_thread = None
|
||||
self.stop_event = threading.Event()
|
||||
self.enabled = True
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""从配置文件加载设置"""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
if 'auto_reconnect' in config:
|
||||
self.reconnect_time = config['auto_reconnect'].get('reconnect_time', '22:00')
|
||||
self.enabled = config['auto_reconnect'].get('enabled', True)
|
||||
self.logger.info(f"加载自动重连配置: 时间={self.reconnect_time}, 启用={self.enabled}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"加载自动重连配置失败,使用默认值: {e}")
|
||||
|
||||
def _save_config(self):
|
||||
"""保存配置到文件"""
|
||||
config = {}
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
if 'auto_reconnect' not in config:
|
||||
config['auto_reconnect'] = {}
|
||||
|
||||
config['auto_reconnect']['reconnect_time'] = self.reconnect_time
|
||||
config['auto_reconnect']['enabled'] = self.enabled
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
self.logger.info(f"自动重连配置已保存: 时间={self.reconnect_time}, 启用={self.enabled}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"保存自动重连配置失败: {e}")
|
||||
|
||||
def _calculate_next_run_time(self):
|
||||
"""计算下一次执行时间"""
|
||||
now = datetime.datetime.now()
|
||||
try:
|
||||
target_time = datetime.datetime.strptime(self.reconnect_time, "%H:%M").time()
|
||||
next_run = datetime.datetime.combine(now.date(), target_time)
|
||||
|
||||
# 如果今天的时间已过,则安排到明天
|
||||
if next_run <= now:
|
||||
next_run += datetime.timedelta(days=1)
|
||||
|
||||
return next_run
|
||||
except ValueError as e:
|
||||
self.logger.error(f"时间格式错误 {self.reconnect_time}: {e}")
|
||||
# 默认返回明天的 22:00
|
||||
next_run = datetime.datetime.combine(now.date() + datetime.timedelta(days=1), datetime.time(22, 0))
|
||||
return next_run
|
||||
|
||||
def _scheduler_loop(self):
|
||||
"""调度器主循环"""
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
if self.enabled:
|
||||
next_run = self._calculate_next_run_time()
|
||||
delay = (next_run - datetime.datetime.now()).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
# 等待直到下一个执行时间,每分钟检查一次停止事件
|
||||
wait_interval = 60 # 每分钟检查一次
|
||||
waited = 0
|
||||
while waited < delay and not self.stop_event.is_set():
|
||||
sleep_time = min(wait_interval, delay - waited)
|
||||
time.sleep(sleep_time)
|
||||
waited += sleep_time
|
||||
|
||||
if not self.stop_event.is_set() and self.enabled:
|
||||
self._scheduled_reconnect()
|
||||
else:
|
||||
# 如果禁用,每分钟检查一次
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
self.logger.error(f"调度器异常: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
def _scheduled_reconnect(self):
|
||||
"""执行定时重连任务"""
|
||||
self.logger.info(f"执行定时重连任务,时间: {self.reconnect_time}")
|
||||
|
||||
# 1. 检测当前连接状态
|
||||
statuses = self.manager.get_all_status()
|
||||
connected_count = sum(1 for s in statuses if s.is_connected)
|
||||
self.logger.info(f"当前连接状态: {connected_count}/{len(statuses)} 个终端在线")
|
||||
|
||||
# 2. 如果有连接,先断开
|
||||
if connected_count > 0:
|
||||
self.logger.info("正在断开所有终端连接...")
|
||||
for unit in self.manager.units.values():
|
||||
try:
|
||||
if unit.xt_trader:
|
||||
unit.cleanup()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"断开终端 {unit.alias} 失败: {e}")
|
||||
|
||||
# 3. 等待几秒后重新连接
|
||||
self.logger.info("等待 3 秒后重新连接...")
|
||||
time.sleep(3)
|
||||
|
||||
# 4. 重新连接所有终端
|
||||
self.logger.info("正在重新连接所有终端...")
|
||||
success_count = 0
|
||||
for unit in self.manager.units.values():
|
||||
if unit.connect():
|
||||
success_count += 1
|
||||
self.logger.info(f"终端 {unit.alias} 重连成功")
|
||||
else:
|
||||
self.logger.warning(f"终端 {unit.alias} 重连失败")
|
||||
|
||||
self.logger.info(f"定时重连完成: {success_count}/{len(self.manager.units)} 个终端重连成功")
|
||||
|
||||
def start(self):
|
||||
"""启动定时任务"""
|
||||
if self.scheduler_thread and self.scheduler_thread.is_alive():
|
||||
self.logger.warning("调度器已在运行中")
|
||||
return
|
||||
|
||||
self.stop_event.clear()
|
||||
self.scheduler_thread = threading.Thread(target=self._scheduler_loop, name="AutoReconnectScheduler", daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
self.logger.info(f"自动重连调度器已启动,重连时间: {self.reconnect_time}, 启用状态: {self.enabled}")
|
||||
|
||||
def stop(self):
|
||||
"""停止定时任务"""
|
||||
self.logger.info("正在停止自动重连调度器...")
|
||||
self.stop_event.set()
|
||||
if self.scheduler_thread and self.scheduler_thread.is_alive():
|
||||
self.scheduler_thread.join(timeout=5)
|
||||
self.logger.info("自动重连调度器已停止")
|
||||
|
||||
def set_reconnect_time(self, time_str):
|
||||
"""
|
||||
设置重连时间。
|
||||
|
||||
参数:
|
||||
- time_str: 时间字符串,格式 "HH:MM"
|
||||
"""
|
||||
try:
|
||||
# 验证时间格式
|
||||
datetime.datetime.strptime(time_str, "%H:%M")
|
||||
old_time = self.reconnect_time
|
||||
self.reconnect_time = time_str
|
||||
self._save_config()
|
||||
self.logger.info(f"重连时间已从 {old_time} 修改为 {time_str}")
|
||||
return True
|
||||
except ValueError as e:
|
||||
self.logger.error(f"时间格式错误 {time_str}: {e}")
|
||||
return False
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
"""设置是否启用自动重连"""
|
||||
self.enabled = enabled
|
||||
self._save_config()
|
||||
self.logger.info(f"自动重连已{'启用' if enabled else '禁用'}")
|
||||
|
||||
def get_config(self):
|
||||
"""获取当前配置"""
|
||||
return {
|
||||
"reconnect_time": self.reconnect_time,
|
||||
"enabled": self.enabled
|
||||
}
|
||||
|
||||
def trigger_reconnect(self):
|
||||
"""手动触发重连(立即执行)"""
|
||||
self.logger.info("手动触发重连任务")
|
||||
threading.Thread(target=self._scheduled_reconnect, name="ManualReconnect", daemon=True).start()
|
||||
|
||||
# ================= 2. 执行单元 (TradingUnit) =================
|
||||
|
||||
class UnitCallback(XtQuantTraderCallback):
|
||||
@@ -385,8 +583,71 @@ class MultiEngineManager:
|
||||
except:
|
||||
self.logger.error(traceback.format_exc())
|
||||
|
||||
def verify_connection(self, timeout=5):
|
||||
"""验证物理连接是否有效"""
|
||||
try:
|
||||
# 先检查回调状态
|
||||
if not self.callback or not self.callback.is_connected:
|
||||
return False
|
||||
|
||||
# 实际调用 API 进行物理探测
|
||||
asset = self.xt_trader.query_stock_asset(self.acc_obj)
|
||||
if asset is not None:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.getLogger("QMT_Engine").warning(f"终端 {self.alias} 物理连接验证失败: {e}")
|
||||
return False
|
||||
|
||||
def get_all_status(self) -> List[TerminalStatus]:
|
||||
return [TerminalStatus(u.qmt_id, u.alias, u.account_id, (u.callback.is_connected if u.callback else False), u.last_heartbeat) for u in self.units.values()]
|
||||
"""获取所有状态,包含物理连接验证"""
|
||||
statuses = []
|
||||
for u in self.units.values():
|
||||
callback_conn = u.callback.is_connected if u.callback else False
|
||||
# 物理探测:通过查询资产确认连接有效性(xtquant 无 verify_connection 方法)
|
||||
physical_conn = False
|
||||
if callback_conn and u.xt_trader and u.acc_obj:
|
||||
try:
|
||||
asset = u.xt_trader.query_stock_asset(u.acc_obj)
|
||||
physical_conn = asset is not None
|
||||
except:
|
||||
physical_conn = False
|
||||
is_connected = callback_conn and physical_conn
|
||||
|
||||
statuses.append(TerminalStatus(
|
||||
qmt_id=u.qmt_id,
|
||||
alias=u.alias,
|
||||
account_id=u.account_id,
|
||||
is_connected=is_connected,
|
||||
callback_connected=callback_conn,
|
||||
physical_connected=physical_conn,
|
||||
last_heartbeat=u.last_heartbeat
|
||||
))
|
||||
return statuses
|
||||
|
||||
def get_logs(self, lines: int = 50) -> List[str]:
|
||||
"""获取最近的系统日志
|
||||
|
||||
参数:
|
||||
lines: 返回的行数,默认50行
|
||||
|
||||
返回:
|
||||
日志行列表
|
||||
"""
|
||||
log_dir = "logs"
|
||||
log_file = os.path.join(log_dir, f"{datetime.date.today().strftime('%Y-%m-%d')}.log")
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8') as f:
|
||||
all_lines = f.readlines()
|
||||
# 返回最后指定行数
|
||||
return all_lines[-lines:] if lines < len(all_lines) else all_lines
|
||||
except Exception as e:
|
||||
self.logger.error(f"读取日志文件失败: {e}")
|
||||
return []
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
|
||||
11
qmt/run.py
11
qmt/run.py
@@ -64,6 +64,17 @@ def main():
|
||||
print("\n>>> Shutdown signal received. Closing terminals...")
|
||||
manager.stop()
|
||||
print(">>> System safely closed.")
|
||||
_write_exit_code(0)
|
||||
sys.exit(0)
|
||||
|
||||
def _write_exit_code(code):
|
||||
"""将退出码写入临时文件,供 start.bat 读取"""
|
||||
try:
|
||||
exit_code_file = os.path.join(os.environ.get('TEMP', ''), 'exit_code.txt')
|
||||
with open(exit_code_file, 'w') as f:
|
||||
f.write(str(code))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 最佳实践:使用 python -u run.py 运行以获得实时日志输出
|
||||
|
||||
118
qmt/start.bat
118
qmt/start.bat
@@ -5,80 +5,112 @@ setlocal enabledelayedexpansion
|
||||
:: ================= 配置选项 =================
|
||||
:: 1. 自动获取当前脚本所在目录
|
||||
set "WORK_DIR=%~dp0"
|
||||
|
||||
:: 2. 设置启动文件
|
||||
set "SCRIPT_NAME=run.py"
|
||||
:: 3. 失败重启配置
|
||||
|
||||
:: 3. 是否使用 uv 管理器 (1=是, 0=否)
|
||||
set USE_UV=1
|
||||
|
||||
:: 4. 显示输出到屏幕还是日志文件 (1=屏幕, 0=日志文件)
|
||||
set SHOW_OUTPUT=1
|
||||
|
||||
:: 5. 失败重启配置
|
||||
set MAX_RETRIES=10
|
||||
set RETRY_COUNT=0
|
||||
set RETRY_WAIT=15
|
||||
:: 4. 日志配置
|
||||
set RETRY_WAIT=10
|
||||
|
||||
:: 6. 日志目录 (仅在 SHOW_OUTPUT=0 时生效)
|
||||
set "LOG_DIR=%WORK_DIR%logs\launcher"
|
||||
:: ===========================================
|
||||
|
||||
cd /d "%WORK_DIR%"
|
||||
title QMT 自动化交易系统 [监控中]
|
||||
title QMT 自动化交易系统 [准备中]
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
:: 构建启动命令前缀
|
||||
if "%USE_UV%"=="1" (
|
||||
set "CMD_PREFIX=uv run python"
|
||||
) else (
|
||||
set "CMD_PREFIX=python"
|
||||
)
|
||||
|
||||
:: 优化日期获取逻辑,防止由于区域设置导致的非法文件名字符
|
||||
for /f "tokens=2 delims==" %%a in ('wmic os get localdatetime /value') do set "dt=%%a"
|
||||
:: 提取前 8 位:YYYYMMDD
|
||||
set "Y=%dt:~0,4%"
|
||||
set "M=%dt:~4,2%"
|
||||
set "D=%dt:~6,2%"
|
||||
set "TODAY=%Y%-%M%-%D%"
|
||||
set "LOG_FILE=%LOG_DIR%\%TODAY%.log"
|
||||
:: 日志与日期初始化
|
||||
if "%SHOW_OUTPUT%"=="0" (
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
for /f "tokens=2 delims==" %%a in ('wmic os get localdatetime /value') do set "dt=%%a"
|
||||
set "TODAY=!dt:~0,4!-!dt:~4,2!-!dt:~6,2!"
|
||||
set "LOG_FILE=%LOG_DIR%\!TODAY!.log"
|
||||
)
|
||||
|
||||
:START_LOOP
|
||||
cls
|
||||
echo ==================================================
|
||||
echo QMT 交易系统守护进程启动
|
||||
echo 工作目录: %WORK_DIR%
|
||||
echo 启动文件: %SCRIPT_NAME%
|
||||
echo 监控地址: http://localhost:8001
|
||||
echo 日志文件: %LOG_FILE%
|
||||
echo 启动命令: %CMD_PREFIX% %SCRIPT_NAME%
|
||||
if "%SHOW_OUTPUT%"=="0" (
|
||||
echo 输出模式: [日志文件] %LOG_FILE%
|
||||
echo 注意: 窗口将不会显示运行日志,请查看上方文件。
|
||||
echo 提示: 如需停止,建议直接关闭窗口。
|
||||
) else (
|
||||
echo 输出模式: [屏幕控制台]
|
||||
echo 提示: 按 Ctrl+C 可终止程序。
|
||||
)
|
||||
echo ==================================================
|
||||
|
||||
:LOOP
|
||||
:LOOP_BODY
|
||||
if %RETRY_COUNT% GEQ %MAX_RETRIES% (
|
||||
echo [%time%] !!! 达到最大重试次数,系统停止重启 !!! >> "%LOG_FILE%"
|
||||
if "%SHOW_OUTPUT%"=="0" echo [%time%] !!! 达到最大重试次数 !!! >> "%LOG_FILE%"
|
||||
color 0C
|
||||
echo 错误: 系统多次崩溃,请检查日志后手动重启。
|
||||
msg * "QMT 系统异常崩溃且无法自动恢复,请检查日志!"
|
||||
goto FAIL
|
||||
echo.
|
||||
:: 【修复】将圆括号改为中括号,防止 if 块被提前截断
|
||||
echo [错误] 达到最大重试次数 [%MAX_RETRIES%],系统停止重启。
|
||||
echo 请检查代码或环境配置。
|
||||
title QMT 系统 - 已停止
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [%time%] 正在启动交易引擎 (第 %RETRY_COUNT% 次重启)...
|
||||
:: 注意:这里将 >>> 改成了 ^>^>^> 进行转义,或者直接改用 ===
|
||||
echo [%time%] ^>^>^> 启动子进程: uv run %SCRIPT_NAME% >> "%LOG_FILE%"
|
||||
title QMT 系统 [运行中] - 重试: %RETRY_COUNT%
|
||||
|
||||
:: 启动程序 (使用 -u 参数确保输出实时刷新到日志)
|
||||
:: 如果你没有安装 uv,请将 uv run 改为 python
|
||||
uv run python -u %SCRIPT_NAME% >> "%LOG_FILE%" 2>&1
|
||||
echo [%time%] 正在启动交易引擎...
|
||||
if "%SHOW_OUTPUT%"=="0" (
|
||||
echo [%time%] ^>^>^> 启动子进程 >> "%LOG_FILE%"
|
||||
%CMD_PREFIX% -u "%SCRIPT_NAME%" >> "%LOG_FILE%" 2>&1
|
||||
) else (
|
||||
%CMD_PREFIX% -u "%SCRIPT_NAME%"
|
||||
)
|
||||
|
||||
set EXIT_CODE=%errorlevel%
|
||||
|
||||
:: 判断退出原因
|
||||
if %EXIT_CODE% EQU 0 (
|
||||
echo [%time%] 程序正常关闭。 >> "%LOG_FILE%"
|
||||
echo 程序运行结束。
|
||||
echo.
|
||||
:: 【修复】将圆括号改为中括号
|
||||
echo [%time%] 程序正常结束 [Exit Code: 0]。
|
||||
if "%SHOW_OUTPUT%"=="0" echo [%time%] 程序正常结束。 >> "%LOG_FILE%"
|
||||
title QMT 系统 - 已完成
|
||||
pause
|
||||
exit /b 0
|
||||
) else if %EXIT_CODE% EQU -1073741510 (
|
||||
echo.
|
||||
:: 【修复】将圆括号改为中括号
|
||||
echo [%time%] 检测到用户手动中断 [Ctrl+C]。守护进程将停止。
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
set /a RETRY_COUNT+=1
|
||||
echo [%time%] 程序异常退出,Code: %EXIT_CODE% >> "%LOG_FILE%"
|
||||
title QMT 系统 [等待重启] - 倒计时
|
||||
|
||||
echo.
|
||||
echo --------------------------------------------------
|
||||
echo [%time%] 警告: 进程意外中断! (退出码: %EXIT_CODE%)
|
||||
echo [%time%] 将在 %RETRY_WAIT% 秒后尝试第 %RETRY_COUNT% 次重启...
|
||||
echo [%time%] 警告: 进程意外崩溃! [Code: %EXIT_CODE%]
|
||||
echo [%time%] 将在 %RETRY_WAIT% 秒后尝试第 %RETRY_COUNT%/%MAX_RETRIES% 次重启...
|
||||
echo --------------------------------------------------
|
||||
|
||||
if "%SHOW_OUTPUT%"=="0" (
|
||||
echo [%time%] 异常退出 Code:%EXIT_CODE%,%RETRY_WAIT%秒后重启... >> "%LOG_FILE%"
|
||||
)
|
||||
|
||||
timeout /t %RETRY_WAIT% >nul
|
||||
goto LOOP
|
||||
|
||||
:FAIL
|
||||
title QMT 系统已停机
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 守护进程已停止。
|
||||
echo 请查看日志: %LOG_FILE%
|
||||
echo ==========================================
|
||||
pause
|
||||
exit /b 1
|
||||
goto LOOP_BODY
|
||||
Reference in New Issue
Block a user