更新qmt代码,支持多端qmt登录
This commit is contained in:
@@ -8,37 +8,46 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from qmt_engine import QMTEngine, QMTStatus
|
||||
# 导入新的管理器类
|
||||
from qmt_engine import MultiEngineManager, TerminalStatus
|
||||
|
||||
|
||||
# ================= Pydantic模型 =================
|
||||
class StatusResponse(BaseModel):
|
||||
"""状态响应模型"""
|
||||
running: bool
|
||||
qmt_connected: bool
|
||||
start_time: str
|
||||
last_loop_update: str
|
||||
account_id: str
|
||||
|
||||
class TerminalStatusModel(BaseModel):
|
||||
"""单个终端状态模型"""
|
||||
qmt_id: str
|
||||
alias: str
|
||||
account_id: str
|
||||
is_connected: bool
|
||||
last_heartbeat: str
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""全局状态响应模型"""
|
||||
running: bool
|
||||
start_time: str
|
||||
terminals: List[TerminalStatusModel]
|
||||
|
||||
class PositionsResponse(BaseModel):
|
||||
"""持仓响应模型"""
|
||||
real_positions: List[Dict[str, Any]]
|
||||
# 按 qmt_id 分组的实盘持仓
|
||||
real_positions: Dict[str, List[Dict[str, Any]]]
|
||||
# 按策略名分组的虚拟持仓
|
||||
virtual_positions: Dict[str, Dict[str, str]]
|
||||
|
||||
|
||||
class LogsResponse(BaseModel):
|
||||
"""日志响应模型"""
|
||||
logs: List[str]
|
||||
|
||||
|
||||
# ================= FastAPI应用 =================
|
||||
|
||||
class QMTAPIServer:
|
||||
"""QMT API服务器"""
|
||||
"""多终端 QMT API服务器"""
|
||||
|
||||
def __init__(self, qmt_engine: QMTEngine):
|
||||
self.app = FastAPI(title="QMT Monitor")
|
||||
self.qmt_engine = qmt_engine
|
||||
def __init__(self, manager: MultiEngineManager):
|
||||
self.app = FastAPI(title="QMT Multi-Terminal Monitor")
|
||||
self.manager = manager
|
||||
self._setup_middleware()
|
||||
self._setup_routes()
|
||||
|
||||
@@ -61,41 +70,78 @@ class QMTAPIServer:
|
||||
return FileResponse("dashboard.html")
|
||||
return {"error": "Dashboard not found"}
|
||||
|
||||
@self.app.get("/api/status", response_model=StatusResponse, summary="获取系统状态")
|
||||
@self.app.get("/api/status", response_model=StatusResponse, summary="获取所有终端状态")
|
||||
def get_status():
|
||||
"""获取QMT连接状态和系统信息"""
|
||||
status = self.qmt_engine.get_status()
|
||||
"""获取所有 QMT 终端的连接状态"""
|
||||
terminal_data = self.manager.get_all_status()
|
||||
|
||||
terminals = [
|
||||
TerminalStatusModel(
|
||||
qmt_id=t.qmt_id,
|
||||
alias=t.alias,
|
||||
account_id=t.account_id,
|
||||
is_connected=t.is_connected,
|
||||
last_heartbeat=t.last_heartbeat
|
||||
) for t in terminal_data
|
||||
]
|
||||
|
||||
return StatusResponse(
|
||||
running=status.is_running,
|
||||
qmt_connected=status.is_connected,
|
||||
start_time=status.start_time,
|
||||
last_loop_update=status.last_heartbeat,
|
||||
account_id=status.account_id
|
||||
running=self.manager.is_running,
|
||||
start_time=self.manager.start_time,
|
||||
terminals=terminals
|
||||
)
|
||||
|
||||
@self.app.get("/api/positions", response_model=PositionsResponse, summary="获取持仓信息")
|
||||
def get_positions():
|
||||
"""获取实盘和虚拟持仓信息"""
|
||||
positions = self.qmt_engine.get_positions()
|
||||
"""汇总所有终端的实盘持仓和所有策略的虚拟持仓"""
|
||||
real_pos_data = {}
|
||||
virtual_pos_data = {}
|
||||
|
||||
# 1. 遍历所有终端单元获取实盘持仓
|
||||
for qmt_id, unit in self.manager.units.items():
|
||||
positions = []
|
||||
if unit.callback and unit.callback.is_connected:
|
||||
try:
|
||||
xt_pos = unit.xt_trader.query_stock_positions(unit.acc_obj)
|
||||
if xt_pos:
|
||||
positions = [
|
||||
{
|
||||
"code": p.stock_code,
|
||||
"volume": p.volume,
|
||||
"can_use": p.can_use_volume,
|
||||
"market_value": round(p.market_value, 2)
|
||||
} for p in xt_pos if p.volume > 0
|
||||
]
|
||||
except:
|
||||
pass
|
||||
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
|
||||
|
||||
return PositionsResponse(
|
||||
real_positions=positions["real_positions"],
|
||||
virtual_positions=positions["virtual_positions"]
|
||||
real_positions=real_pos_data,
|
||||
virtual_positions=virtual_pos_data
|
||||
)
|
||||
|
||||
@self.app.get("/api/logs", response_model=LogsResponse, summary="获取日志")
|
||||
def get_logs(lines: int = Query(50, ge=1, le=1000, description="返回日志行数")):
|
||||
"""获取最近的交易日志"""
|
||||
logs = self.qmt_engine.get_logs(lines)
|
||||
@self.app.get("/api/logs", response_model=LogsResponse, summary="获取系统日志")
|
||||
def get_logs(lines: int = Query(50, ge=1, le=1000)):
|
||||
"""获取最近的系统运行日志"""
|
||||
logs = self.manager.get_logs(lines)
|
||||
return LogsResponse(logs=logs)
|
||||
|
||||
@self.app.get("/api/health", summary="健康检查")
|
||||
def health_check():
|
||||
"""健康检查接口"""
|
||||
status = self.qmt_engine.get_status()
|
||||
if status.is_running and status.is_connected:
|
||||
"""健康检查:只要有一个终端在线即视为正常"""
|
||||
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", "timestamp": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
return {"status": "unhealthy", "reason": "No terminals connected", "timestamp": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
def get_app(self) -> FastAPI:
|
||||
"""获取FastAPI应用实例"""
|
||||
@@ -103,7 +149,8 @@ class QMTAPIServer:
|
||||
|
||||
|
||||
# ================= 辅助函数 =================
|
||||
def create_api_server(qmt_engine: QMTEngine) -> FastAPI:
|
||||
"""创建API服务器"""
|
||||
server = QMTAPIServer(qmt_engine)
|
||||
return server.get_app()
|
||||
|
||||
def create_api_server(manager: MultiEngineManager) -> FastAPI:
|
||||
"""创建API服务器入口"""
|
||||
server = QMTAPIServer(manager)
|
||||
return server.get_app()
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QMT 实盘监控看板</title>
|
||||
<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>
|
||||
@@ -13,23 +13,18 @@
|
||||
.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: 400px;
|
||||
overflow-y: scroll;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
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; }
|
||||
.log-line:hover { background-color: #2a2a2a; }
|
||||
.virtual-item { margin-bottom: 20px; border-left: 4px solid #409EFF; padding-left: 10px; }
|
||||
.virtual-title { font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #606266; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,86 +36,100 @@
|
||||
<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>
|
||||
<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;">
|
||||
API: {{ status.running ? 'Running' : 'Offline' }}
|
||||
</el-tag>
|
||||
<el-tag :type="status.qmt_connected ? 'success' : 'danger'" effect="dark">
|
||||
QMT: {{ status.qmt_connected ? 'Connected' : 'Disconnected' }}
|
||||
系统: {{ status.running ? '运行中' : '离线' }}
|
||||
</el-tag>
|
||||
<el-button type="primary" :icon="Refresh" @click="manualRefresh" :loading="loading">手动刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions border :column="4" size="large">
|
||||
<el-descriptions-item label="资金账号">{{ status.account_id || '---' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="启动时间">{{ status.start_time || '---' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="心跳时间">
|
||||
<span :style="{color: isHeartbeatStalled ? 'red' : 'green', fontWeight: 'bold'}">
|
||||
{{ status.last_loop_update || '---' }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="控制">
|
||||
<el-button type="primary" :icon="Refresh" @click="manualRefresh" :loading="loading">手动刷新</el-button>
|
||||
|
||||
<div style="margin-left: 15px; display: inline-flex; align-items: center;">
|
||||
<el-checkbox v-model="autoRefresh" label="自动刷新(1min)" border></el-checkbox>
|
||||
<span style="font-size: 12px; margin-left: 8px; color: #909399;">
|
||||
{{ tradingStatusText }}
|
||||
</span>
|
||||
|
||||
<!-- 终端状态概览 -->
|
||||
<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-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</el-header>
|
||||
|
||||
<el-main style="padding: 0;">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<!-- 左侧:多账号实盘持仓 -->
|
||||
<el-col :span="13">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><span class="status-badge bg-green"></span>实盘真实持仓 (QMT)</span>
|
||||
<span><el-icon><Suitcase /></el-icon> 实盘真实持仓 (按终端分组)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="positions.real_positions" style="width: 100%" border stripe size="small" empty-text="当前空仓">
|
||||
<el-table-column prop="code" label="代码" width="100" sortable></el-table-column>
|
||||
<el-table-column prop="volume" label="总持仓" width="100"></el-table-column>
|
||||
<el-table-column prop="can_use" label="可用" width="100"></el-table-column>
|
||||
<el-table-column prop="market_value" label="市值"></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<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>
|
||||
|
||||
<el-col :span="12">
|
||||
<!-- 右侧:Redis 虚拟账本 -->
|
||||
<el-col :span="11">
|
||||
<el-card class="box-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><span class="status-badge bg-gray"></span>Redis 虚拟账本 (策略隔离)</span>
|
||||
<span><el-icon><Memo /></el-icon> Redis 虚拟账本 (策略隔离)</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="Object.keys(positions.virtual_positions).length === 0" style="color:#909399; text-align:center; padding: 20px;">
|
||||
暂无策略数据 / Redis未连接
|
||||
</div>
|
||||
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" class="virtual-item">
|
||||
<div class="virtual-title">{{ strategyName }}</div>
|
||||
<el-table :data="formatVirtual(posObj)" style="width: 100%;" border size="small" empty-text="该策略当前空仓">
|
||||
<el-table-column prop="code" label="代码"></el-table-column>
|
||||
<el-table-column prop="vol" label="记账数量">
|
||||
<template #default="scope"><span style="font-weight: bold;">{{ scope.row.vol }}</span></template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
|
||||
<!-- 底部:日志 -->
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="box-card" shadow="never">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header"><span>系统实时日志 (Last 50 lines)</span></div>
|
||||
<div class="card-header"><span>系统实时日志 (最新 50 条)</span></div>
|
||||
</template>
|
||||
<div class="log-box" ref="logBox">
|
||||
<div v-for="(line, index) in logs" :key="index" class="log-line">{{ line }}</div>
|
||||
@@ -134,122 +143,92 @@
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
|
||||
const { Monitor, Refresh } = ElementPlusIconsVue;
|
||||
const { Monitor, Refresh, Suitcase, Memo } = ElementPlusIconsVue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const status = ref({});
|
||||
const positions = ref({ real_positions: [], virtual_positions: {} });
|
||||
const status = ref({ running: false, terminals: [], start_time: "" });
|
||||
const positions = ref({ real_positions: {}, virtual_positions: {} });
|
||||
const logs = ref([]);
|
||||
const autoRefresh = ref(true); // 默认开启自动刷新
|
||||
const autoRefresh = ref(true);
|
||||
const loading = ref(false);
|
||||
const logBox = ref(null);
|
||||
let timer = null;
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
// === 核心逻辑修改:判断是否为交易时间 ===
|
||||
const isTradingTime = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const currentTimeVal = hour * 100 + minute;
|
||||
|
||||
// 1. 判断是否为周末 (0是周日, 6是周六)
|
||||
if (day === 0 || day === 6) return false;
|
||||
|
||||
// 2. 判断时间段 (09:00 - 15:10)
|
||||
// 包含集合竞价和收盘清算时间
|
||||
if (currentTimeVal >= 900 && currentTimeVal <= 1510) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const val = now.getHours() * 100 + now.getMinutes();
|
||||
return (val >= 900 && val <= 1515);
|
||||
};
|
||||
|
||||
// 界面显示的提示文本
|
||||
const tradingStatusText = computed(() => {
|
||||
if (!autoRefresh.value) return "已关闭";
|
||||
return isTradingTime() ? "监控中..." : "休市暂停";
|
||||
});
|
||||
|
||||
const isHeartbeatStalled = computed(() => {
|
||||
if (!status.value.last_loop_update) return true;
|
||||
return false;
|
||||
return isTradingTime() ? "市场开放中" : "非交易时段";
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const resStatus = await fetch(`${API_BASE}/api/status`);
|
||||
if(resStatus.ok) status.value = await resStatus.json();
|
||||
else status.value = { running: false };
|
||||
const sRes = await fetch(`${API_BASE}/api/status`);
|
||||
if(sRes.ok) status.value = await sRes.json();
|
||||
|
||||
const resPos = await fetch(`${API_BASE}/api/positions`);
|
||||
if(resPos.ok) positions.value = await resPos.json();
|
||||
const pRes = await fetch(`${API_BASE}/api/positions`);
|
||||
if(pRes.ok) positions.value = await pRes.json();
|
||||
|
||||
const resLogs = await fetch(`${API_BASE}/api/logs`);
|
||||
if(resLogs.ok) {
|
||||
const logData = await resLogs.json();
|
||||
const needScroll = (logs.value.length !== logData.logs.length);
|
||||
logs.value = logData.logs;
|
||||
|
||||
// 只有在自动刷新且有新日志时才自动滚动
|
||||
if (needScroll && autoRefresh.value) {
|
||||
setTimeout(() => {
|
||||
if(logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
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("API Error:", e);
|
||||
status.value.running = false;
|
||||
console.error("Fetch Error:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 手动刷新按钮:不受时间限制
|
||||
const manualRefresh = () => {
|
||||
fetchData();
|
||||
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) => {
|
||||
if (!obj) return [];
|
||||
return Object.keys(obj).map(key => ({ code: key, vol: obj[key] }));
|
||||
return Object.keys(obj).map(k => ({ code: k, vol: obj[k] }));
|
||||
};
|
||||
|
||||
const manualRefresh = () => fetchData();
|
||||
|
||||
onMounted(() => {
|
||||
// 页面加载时先拉取一次
|
||||
fetchData();
|
||||
|
||||
// === 修改定时器:每 60 秒触发一次 ===
|
||||
timer = setInterval(() => {
|
||||
// 只有在 "开关开启" 且 "处于交易时间" 时才请求
|
||||
if (autoRefresh.value && isTradingTime()) {
|
||||
fetchData();
|
||||
}
|
||||
}, 60000); // 60000ms = 1分钟
|
||||
if (autoRefresh.value && isTradingTime()) fetchData();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
onUnmounted(() => { if (timer) clearInterval(timer); });
|
||||
|
||||
return {
|
||||
status, positions, logs, autoRefresh, loading, logBox,
|
||||
manualRefresh, // 绑定到按钮
|
||||
fetchData,
|
||||
formatVirtual,
|
||||
isHeartbeatStalled,
|
||||
tradingStatusText, // 绑定到提示文本
|
||||
Monitor, Refresh
|
||||
manualRefresh, formatVirtual, tradingStatusText,
|
||||
getTerminalAlias, getTerminalStatusClass,
|
||||
Monitor, Refresh, Suitcase, Memo
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
app.component(key, component);
|
||||
}
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -16,32 +16,27 @@ from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtconstant
|
||||
|
||||
# ================= 0. Windows 控制台防卡死补丁 =================
|
||||
# ================= 0. Windows 补丁 =================
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
# 禁用快速编辑模式 (0x0040),防止鼠标点击终端导致程序挂起
|
||||
kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 128)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class QMTStatus:
|
||||
"""系统状态封装类"""
|
||||
is_connected: bool
|
||||
start_time: str
|
||||
last_heartbeat: str
|
||||
class TerminalStatus:
|
||||
"""终端实例状态封装"""
|
||||
qmt_id: str
|
||||
alias: str
|
||||
account_id: str
|
||||
is_running: bool
|
||||
is_connected: bool
|
||||
last_heartbeat: str
|
||||
|
||||
|
||||
# ================= 1. 虚拟持仓与对账辅助类 =================
|
||||
# ================= 1. 业务逻辑辅助类 =================
|
||||
|
||||
class PositionManager:
|
||||
"""Redis 持仓管理器:负责维护每个子策略的虚拟仓位"""
|
||||
|
||||
"""Redis 虚拟持仓管理(全局单例)"""
|
||||
def __init__(self, r_client):
|
||||
self.r = r_client
|
||||
|
||||
@@ -49,18 +44,15 @@ class PositionManager:
|
||||
return f"POS:{strategy_name}"
|
||||
|
||||
def mark_holding(self, strategy_name, code):
|
||||
"""下单时先在 Redis 占位(0股),占用一个槽位"""
|
||||
self.r.hsetnx(self._get_key(strategy_name), code, 0)
|
||||
|
||||
def rollback_holding(self, strategy_name, code):
|
||||
"""报单失败时回滚,释放 Redis 占位"""
|
||||
key = self._get_key(strategy_name)
|
||||
val = self.r.hget(key, code)
|
||||
if val is not None and int(val) == 0:
|
||||
self.r.hdel(key, code)
|
||||
|
||||
def update_actual_volume(self, strategy_name, code, delta_vol):
|
||||
"""成交回调时更新 Redis 实际股数"""
|
||||
key = self._get_key(strategy_name)
|
||||
new_vol = self.r.hincrby(key, code, int(delta_vol))
|
||||
if new_vol <= 0:
|
||||
@@ -69,12 +61,10 @@ class PositionManager:
|
||||
return new_vol
|
||||
|
||||
def get_position(self, strategy_name, code):
|
||||
"""获取某个策略下某只股票的虚拟持仓"""
|
||||
vol = self.r.hget(self._get_key(strategy_name), code)
|
||||
return int(vol) if vol else 0
|
||||
|
||||
def get_holding_count(self, strategy_name):
|
||||
"""获取当前策略已占用的槽位总数"""
|
||||
return self.r.hlen(self._get_key(strategy_name))
|
||||
|
||||
def get_all_virtual_positions(self, strategy_name):
|
||||
@@ -83,98 +73,120 @@ class PositionManager:
|
||||
def force_delete(self, strategy_name, code):
|
||||
self.r.hdel(self._get_key(strategy_name), code)
|
||||
|
||||
def clean_stale_placeholders(self, strategy_name, xt_trader, acc):
|
||||
"""清理长时间未成交且实盘无持仓的占位符"""
|
||||
try:
|
||||
key = self._get_key(strategy_name)
|
||||
all_pos = self.r.hgetall(key)
|
||||
if not all_pos: return
|
||||
|
||||
active_orders = xt_trader.query_stock_orders(acc, cancelable_only=True)
|
||||
active_codes = [o.stock_code for o in active_orders] if active_orders else []
|
||||
real_positions = xt_trader.query_stock_positions(acc)
|
||||
real_holdings = [p.stock_code for p in real_positions if p.volume > 0] if real_positions else []
|
||||
|
||||
for code, vol_str in all_pos.items():
|
||||
if int(vol_str) == 0:
|
||||
if (code not in real_holdings) and (code not in active_codes):
|
||||
self.r.hdel(key, code)
|
||||
except Exception as e:
|
||||
logging.getLogger("QMT_Engine").error(f"清理占位异常: {e}")
|
||||
|
||||
|
||||
class DailySettlement:
|
||||
"""收盘对账逻辑"""
|
||||
|
||||
def __init__(self, xt_trader, acc, pos_mgr, strategies_config):
|
||||
self.trader = xt_trader
|
||||
self.acc = acc
|
||||
self.pos_mgr = pos_mgr
|
||||
self.strategies_config = strategies_config
|
||||
"""终端级别的日终对账"""
|
||||
def __init__(self, unit):
|
||||
self.unit = unit
|
||||
self.has_settled = False
|
||||
|
||||
def run_settlement(self):
|
||||
"""收盘后强制同步 Redis 和实盘持仓"""
|
||||
real_positions = self.trader.query_stock_positions(self.acc)
|
||||
trader = self.unit.xt_trader
|
||||
acc = self.unit.acc_obj
|
||||
if not trader: return
|
||||
|
||||
real_positions = trader.query_stock_positions(acc)
|
||||
real_pos_map = {p.stock_code: p.volume for p in real_positions if p.volume > 0} if real_positions else {}
|
||||
|
||||
for strategy in self.strategies_config.keys():
|
||||
virtual = self.pos_mgr.get_all_virtual_positions(strategy)
|
||||
|
||||
manager = MultiEngineManager()
|
||||
strategies = manager.get_strategies_by_terminal(self.unit.qmt_id)
|
||||
for s_name in strategies:
|
||||
virtual = manager.pos_manager.get_all_virtual_positions(s_name)
|
||||
for code, v_str in virtual.items():
|
||||
v = int(v_str)
|
||||
if code not in real_pos_map:
|
||||
self.pos_mgr.force_delete(strategy, code)
|
||||
elif v == 0 and code in real_pos_map:
|
||||
self.pos_mgr.update_actual_volume(strategy, code, real_pos_map[code])
|
||||
manager.pos_manager.force_delete(s_name, code)
|
||||
elif int(v_str) == 0 and code in real_pos_map:
|
||||
manager.pos_manager.update_actual_volume(s_name, code, real_pos_map[code])
|
||||
self.has_settled = True
|
||||
|
||||
def reset_flag(self):
|
||||
"""重置结算标志,以便第二天重新执行"""
|
||||
self.has_settled = False
|
||||
|
||||
# ================= 2. 执行单元 (TradingUnit) =================
|
||||
|
||||
# ================= 2. QMT 核心引擎 =================
|
||||
|
||||
class MyXtQuantTraderCallback(XtQuantTraderCallback):
|
||||
"""交易回调事件监听"""
|
||||
|
||||
def __init__(self, pos_mgr):
|
||||
self.pos_mgr = pos_mgr
|
||||
class UnitCallback(XtQuantTraderCallback):
|
||||
def __init__(self, unit):
|
||||
self.unit = unit
|
||||
self.is_connected = False
|
||||
self.logger = logging.getLogger("QMT_Engine")
|
||||
|
||||
def on_disconnected(self):
|
||||
self.logger.warning(">> 回调通知: 交易端连接断开")
|
||||
logging.getLogger("QMT_Engine").warning(f"终端 {self.unit.alias}({self.unit.qmt_id}) 物理连接断开")
|
||||
self.is_connected = False
|
||||
|
||||
def on_stock_trade(self, trade):
|
||||
try:
|
||||
# QMTEngine 是单例,可直接通过类访问
|
||||
cache_info = QMTEngine().order_cache.get(trade.order_id)
|
||||
cache_info = self.unit.order_cache.get(trade.order_id)
|
||||
if not cache_info: return
|
||||
strategy, _, action = cache_info
|
||||
self.logger.info(f">>> [成交] {strategy} | {trade.stock_code} | {trade.traded_volume}股")
|
||||
s_name, _, action = cache_info
|
||||
manager = MultiEngineManager()
|
||||
if action == 'BUY':
|
||||
self.pos_mgr.update_actual_volume(strategy, trade.stock_code, trade.traded_volume)
|
||||
manager.pos_manager.update_actual_volume(s_name, trade.stock_code, trade.traded_volume)
|
||||
elif action == 'SELL':
|
||||
self.pos_mgr.update_actual_volume(strategy, trade.stock_code, -trade.traded_volume)
|
||||
except:
|
||||
traceback.print_exc()
|
||||
manager.pos_manager.update_actual_volume(s_name, trade.stock_code, -trade.traded_volume)
|
||||
except:
|
||||
logging.getLogger("QMT_Engine").error(traceback.format_exc())
|
||||
|
||||
def on_order_error(self, err):
|
||||
cache = self.unit.order_cache.get(err.order_id)
|
||||
if cache and cache[2] == 'BUY':
|
||||
MultiEngineManager().pos_manager.rollback_holding(cache[0], cache[1])
|
||||
self.unit.order_cache.pop(err.order_id, None)
|
||||
|
||||
class TradingUnit:
|
||||
"""终端实例执行单元,负责管理单个 QMT 进程"""
|
||||
def __init__(self, t_cfg):
|
||||
self.qmt_id = t_cfg['qmt_id']
|
||||
self.alias = t_cfg.get('alias', self.qmt_id)
|
||||
self.path = t_cfg['path']
|
||||
self.account_id = t_cfg['account_id']
|
||||
self.account_type = t_cfg['account_type']
|
||||
|
||||
self.xt_trader = None
|
||||
self.acc_obj = None
|
||||
self.callback = None
|
||||
self.settler = None
|
||||
self.order_cache = {}
|
||||
self.last_heartbeat = "N/A"
|
||||
|
||||
def cleanup(self):
|
||||
"""强制销毁资源,确保文件句柄释放"""
|
||||
if self.xt_trader:
|
||||
try:
|
||||
logging.getLogger("QMT_Engine").info(f"正在销毁终端 {self.alias} 的旧资源...")
|
||||
self.xt_trader.stop()
|
||||
self.xt_trader = None # 显式置空
|
||||
self.callback = None
|
||||
time.sleep(1.5) # 给 C++ 引擎留出释放 down_queue 锁的时间
|
||||
except:
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
"""连接 QMT 终端"""
|
||||
self.cleanup() # 启动前先执行清理
|
||||
try:
|
||||
self.logger.error(f"下单失败回调: {err.error_msg} ID:{err.order_id}")
|
||||
cache = QMTEngine().order_cache.get(err.order_id)
|
||||
if cache and cache[2] == 'BUY':
|
||||
self.pos_mgr.rollback_holding(cache[0], cache[1])
|
||||
if err.order_id in QMTEngine().order_cache:
|
||||
del QMTEngine().order_cache[err.order_id]
|
||||
except:
|
||||
pass
|
||||
# 采用动态 Session ID 避免冲突
|
||||
session_id = int(time.time()) + hash(self.qmt_id) % 1000
|
||||
self.xt_trader = XtQuantTrader(self.path, session_id)
|
||||
self.acc_obj = StockAccount(self.account_id, self.account_type)
|
||||
self.callback = UnitCallback(self)
|
||||
|
||||
self.xt_trader.register_callback(self.callback)
|
||||
self.xt_trader.start()
|
||||
res = self.xt_trader.connect()
|
||||
|
||||
if res == 0:
|
||||
self.xt_trader.subscribe(self.acc_obj)
|
||||
self.callback.is_connected = True
|
||||
self.settler = DailySettlement(self)
|
||||
logging.getLogger("QMT_Engine").info(f"终端 {self.alias} 连接成功 (SID: {session_id})")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.getLogger("QMT_Engine").error(f"终端 {self.alias} 连接异常: {repr(e)}")
|
||||
return False
|
||||
|
||||
# ================= 3. 总控中心 (MultiEngineManager) =================
|
||||
|
||||
class QMTEngine:
|
||||
"""QMT 交易引擎单例"""
|
||||
class MultiEngineManager:
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -187,244 +199,196 @@ class QMTEngine:
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'): return
|
||||
self.logger = None
|
||||
self.units: Dict[str, TradingUnit] = {}
|
||||
self.config = {}
|
||||
self.xt_trader = None
|
||||
self.acc = None
|
||||
self.pos_manager = None
|
||||
self.callback = None
|
||||
self.is_running = True
|
||||
self.start_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.last_heartbeat = "Initializing..."
|
||||
self.order_cache = {} # OrderID -> (Strategy, Code, Action)
|
||||
self.settler = None
|
||||
self._initialized = True
|
||||
|
||||
def initialize(self, config_file='config.json'):
|
||||
self._setup_logger()
|
||||
self.config = self._load_config(config_file)
|
||||
# 初始化 Redis
|
||||
try:
|
||||
self.redis_client = redis.Redis(**self.config['redis'], decode_responses=True)
|
||||
self.redis_client.ping()
|
||||
self.pos_manager = PositionManager(self.redis_client)
|
||||
self.logger.info("Redis 建立连接成功")
|
||||
except Exception as e:
|
||||
self.logger.critical(f"Redis 连接失败: {e}")
|
||||
raise
|
||||
self._reconnect_qmt()
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
self.r = redis.Redis(**self.config['redis'], decode_responses=True)
|
||||
self.pos_manager = PositionManager(self.r)
|
||||
|
||||
for t_cfg in self.config.get('qmt_terminals', []):
|
||||
unit = TradingUnit(t_cfg)
|
||||
unit.connect()
|
||||
self.units[unit.qmt_id] = unit
|
||||
|
||||
def _setup_logger(self):
|
||||
log_dir = "logs"
|
||||
if not os.path.exists(log_dir): os.makedirs(log_dir)
|
||||
log_file = os.path.join(log_dir, f"{datetime.date.today().strftime('%Y-%m-%d')}.log")
|
||||
self.logger = logging.getLogger("QMT_Engine")
|
||||
self.logger.setLevel(logging.INFO)
|
||||
if self.logger.handlers:
|
||||
for h in self.logger.handlers[:]: h.close(); self.logger.removeHandler(h)
|
||||
fmt = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(threadName)s] %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||
logger = logging.getLogger("QMT_Engine")
|
||||
logger.setLevel(logging.INFO)
|
||||
# 确保日志流为 UTF-8
|
||||
fmt = logging.Formatter('[%(asctime)s] [%(threadName)s] %(message)s', '%H:%M:%S')
|
||||
fh = logging.FileHandler(log_file, mode='a', encoding='utf-8')
|
||||
fh.setFormatter(fmt)
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
self.logger.addHandler(fh);
|
||||
self.logger.addHandler(sh)
|
||||
logger.addHandler(fh)
|
||||
logger.addHandler(sh)
|
||||
|
||||
def _load_config(self, config_file):
|
||||
base = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(
|
||||
os.path.abspath(__file__))
|
||||
path = os.path.join(base, config_file)
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def _get_global_total_slots(self):
|
||||
"""本地引擎计算:所有策略总共分配了多少个槽位"""
|
||||
return sum(info.get('total_slots', 0) for info in self.config.get('strategies', {}).values())
|
||||
|
||||
def _get_execution_setting(self, strategy_name, key, default=None):
|
||||
"""扩展性配置读取:读取 execution 字典中的参数"""
|
||||
strat_cfg = self.config.get('strategies', {}).get(strategy_name, {})
|
||||
exec_cfg = strat_cfg.get('execution', {})
|
||||
return exec_cfg.get(key, default)
|
||||
|
||||
def _reconnect_qmt(self):
|
||||
q = self.config['qmt']
|
||||
if self.xt_trader:
|
||||
try:
|
||||
self.xt_trader.stop()
|
||||
except:
|
||||
pass
|
||||
self.xt_trader = XtQuantTrader(q['path'], int(time.time()))
|
||||
self.acc = StockAccount(q['account_id'], q['account_type'])
|
||||
self.callback = MyXtQuantTraderCallback(self.pos_manager)
|
||||
self.xt_trader.register_callback(self.callback)
|
||||
self.xt_trader.start()
|
||||
if self.xt_trader.connect() == 0:
|
||||
self.xt_trader.subscribe(self.acc)
|
||||
self.callback.is_connected = True
|
||||
self.settler = DailySettlement(self.xt_trader, self.acc, self.pos_manager, self.config['strategies'])
|
||||
for s in self.config['strategies'].keys():
|
||||
self.pos_manager.clean_stale_placeholders(s, self.xt_trader, self.acc)
|
||||
self.logger.info("✅ QMT 终端连接成功")
|
||||
return True
|
||||
return False
|
||||
|
||||
def process_strategy_queue(self, strategy_name):
|
||||
"""处理 Redis 中的策略信号"""
|
||||
queue_key = f"{strategy_name}_real"
|
||||
msg_json = self.redis_client.lpop(queue_key)
|
||||
if not msg_json: return
|
||||
try:
|
||||
self.redis_client.rpush(f"{queue_key}:history", msg_json)
|
||||
data = json.loads(msg_json)
|
||||
if data.get('is_backtest'): return
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
if data.get('timestamp', '').split(' ')[0] != today: return
|
||||
|
||||
action = data.get('action')
|
||||
stock = data.get('stock_code')
|
||||
price = float(data.get('price', 0))
|
||||
msg_slots = int(data.get('total_slots', 0))
|
||||
|
||||
if action == 'BUY':
|
||||
self._process_buy(strategy_name, stock, price, msg_slots)
|
||||
elif action == 'SELL':
|
||||
self._process_sell(strategy_name, stock, price)
|
||||
except Exception as e:
|
||||
self.logger.error(f"消息解析异常: {e}")
|
||||
|
||||
def _process_buy(self, strategy_name, stock_code, price, msg_slots):
|
||||
"""核心开仓逻辑"""
|
||||
# 1. 验证配置
|
||||
strat_cfg = self.config.get('strategies', {}).get(strategy_name)
|
||||
if not strat_cfg: return
|
||||
local_slots = strat_cfg.get('total_slots', 0)
|
||||
|
||||
# 2. 安全校验:信号槽位与本地实盘配置必须严格一致
|
||||
if msg_slots != local_slots:
|
||||
self.logger.error(f"⚠️ [{strategy_name}] 槽位不匹配!拒绝下单。信号预期:{msg_slots} | 本地配置:{local_slots}")
|
||||
return
|
||||
|
||||
# 3. 检查子策略占用
|
||||
if self.pos_manager.get_holding_count(strategy_name) >= local_slots:
|
||||
self.logger.warning(f"[{strategy_name}] 槽位已满,拦截买入 {stock_code}")
|
||||
return
|
||||
|
||||
# 4. 资金计算(由本地引擎统筹全局)
|
||||
try:
|
||||
asset = self.xt_trader.query_stock_asset(self.acc)
|
||||
global_total = self._get_global_total_slots()
|
||||
if not asset or global_total <= 0: return
|
||||
|
||||
# 单笔预算 = (总资产现金 + 持仓市值) / 全局总槽位
|
||||
total_equity = asset.cash + asset.market_value
|
||||
target_amt = total_equity / global_total
|
||||
# 实际可用金额不超过现金的 98%(预留滑点/手续费)
|
||||
actual_amt = min(target_amt, asset.cash * 0.98)
|
||||
|
||||
if actual_amt < 2000:
|
||||
self.logger.warning(f"[{strategy_name}] 可用金额不足2000,取消买入 {stock_code}")
|
||||
return
|
||||
|
||||
# --- 价格偏移处理 ---
|
||||
offset = self._get_execution_setting(strategy_name, 'buy_price_offset', 0.0)
|
||||
final_price = round(price + offset, 3)
|
||||
|
||||
vol = int(actual_amt / (final_price if final_price > 0 else 1.0) / 100) * 100
|
||||
if vol < 100: return
|
||||
|
||||
oid = self.xt_trader.order_stock(self.acc, stock_code, xtconstant.STOCK_BUY, vol, xtconstant.FIX_PRICE,
|
||||
final_price, strategy_name, 'PyBuy')
|
||||
if oid != -1:
|
||||
self.logger.info(
|
||||
f"√√√ [{strategy_name}] 开仓下单: {stock_code} | 价格:{final_price}(加价:{offset}) | 数量:{vol}")
|
||||
self.order_cache[oid] = (strategy_name, stock_code, 'BUY')
|
||||
self.pos_manager.mark_holding(strategy_name, stock_code)
|
||||
else:
|
||||
self.logger.error(f"XXX [{strategy_name}] 开仓发单拒绝")
|
||||
except Exception as e:
|
||||
self.logger.error(f"买入异常: {e}", exc_info=True)
|
||||
|
||||
def _process_sell(self, strategy_name, stock_code, price):
|
||||
"""核心平仓逻辑"""
|
||||
v_vol = self.pos_manager.get_position(strategy_name, stock_code)
|
||||
if v_vol <= 0: return
|
||||
|
||||
real_pos = self.xt_trader.query_stock_positions(self.acc)
|
||||
rp = next((p for p in real_pos if p.stock_code == stock_code), None) if real_pos else None
|
||||
can_use = rp.can_use_volume if rp else 0
|
||||
|
||||
final_vol = min(v_vol, can_use)
|
||||
if final_vol <= 0:
|
||||
self.logger.warning(f"[{strategy_name}] {stock_code} 无可用平仓额度 (Redis:{v_vol}, 实盘:{can_use})")
|
||||
return
|
||||
|
||||
# --- 价格偏移处理 ---
|
||||
offset = self._get_execution_setting(strategy_name, 'sell_price_offset', 0.0)
|
||||
final_price = round(price + offset, 3)
|
||||
|
||||
oid = self.xt_trader.order_stock(self.acc, stock_code, xtconstant.STOCK_SELL, final_vol, xtconstant.FIX_PRICE,
|
||||
final_price, strategy_name, 'PySell')
|
||||
if oid != -1:
|
||||
self.logger.info(
|
||||
f"√√√ [{strategy_name}] 平仓下单: {stock_code} | 价格:{final_price}(偏移:{offset}) | 数量:{final_vol}")
|
||||
self.order_cache[oid] = (strategy_name, stock_code, 'SELL')
|
||||
def get_strategies_by_terminal(self, qmt_id):
|
||||
return [s for s, cfg in self.config['strategies'].items() if cfg.get('qmt_id') == qmt_id]
|
||||
|
||||
def run_trading_loop(self):
|
||||
"""交易主线程循环"""
|
||||
self.logger.info(">>> 交易主循环子线程已启动 <<<")
|
||||
self.logger = logging.getLogger("QMT_Engine")
|
||||
self.logger.info(">>> 多终端交易主循环线程已启动 <<<")
|
||||
last_check = 0
|
||||
while self.is_running:
|
||||
try:
|
||||
self.last_heartbeat = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
# 健康检查
|
||||
if time.time() - last_check > 15:
|
||||
last_check = time.time()
|
||||
try:
|
||||
if not (self.xt_trader and self.acc and self.xt_trader.query_stock_asset(self.acc)):
|
||||
self._reconnect_qmt()
|
||||
except:
|
||||
self._reconnect_qmt()
|
||||
now_t = time.time()
|
||||
curr_hms = datetime.datetime.now().strftime('%H%M%S')
|
||||
|
||||
# --- 健康检查与自动修复 ---
|
||||
if now_t - last_check > 25:
|
||||
last_check = now_t
|
||||
for unit in self.units.values():
|
||||
is_unit_alive = False
|
||||
if unit.xt_trader and unit.acc_obj:
|
||||
try:
|
||||
# 物理探测:通过查资产确认连接有效性
|
||||
asset = unit.xt_trader.query_stock_asset(unit.acc_obj)
|
||||
if asset:
|
||||
is_unit_alive = True
|
||||
unit.last_heartbeat = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
# 状态修正:物理通但逻辑False时自动拉回
|
||||
if unit.callback and not unit.callback.is_connected:
|
||||
unit.callback.is_connected = True
|
||||
self.logger.info(f"✅ 修正终端 {unit.alias} 状态为在线")
|
||||
except:
|
||||
is_unit_alive = False
|
||||
|
||||
# 交易时间判断
|
||||
curr = datetime.datetime.now().strftime('%H%M%S')
|
||||
is_trading = ('091500' <= curr <= '113000') or ('130000' <= curr <= '150000')
|
||||
# 断线重连策略
|
||||
if not is_unit_alive:
|
||||
# 避让 QMT 夜间重启高峰 (21:32 - 21:50)
|
||||
if not ('213200' <= curr_hms <= '215000'):
|
||||
self.logger.warning(f"🚫 终端 {unit.alias} 物理连接丢失,执行重连...")
|
||||
unit.connect()
|
||||
else:
|
||||
self.logger.info(f"⏳ 处于 QMT 重启时段 ({curr_hms}),跳过重连操作...")
|
||||
|
||||
if is_trading and self.callback and self.callback.is_connected:
|
||||
if self.settler: self.settler.reset_flag()
|
||||
for s in self.config.get('strategies', {}).keys():
|
||||
self.process_strategy_queue(s)
|
||||
elif '150500' <= curr <= '151500' and self.settler and not self.settler.has_settled:
|
||||
self.settler.run_settlement()
|
||||
# --- 交易逻辑处理 ---
|
||||
is_trading = ('091500' <= curr_hms <= '113030') or ('130000' <= curr_hms <= '150030')
|
||||
if is_trading:
|
||||
for s_name in self.config['strategies'].keys():
|
||||
self.process_route(s_name)
|
||||
|
||||
# --- 收盘结算与标志位重置 ---
|
||||
elif '150500' <= curr_hms <= '151500':
|
||||
for unit in self.units.values():
|
||||
if unit.settler and not unit.settler.has_settled:
|
||||
unit.settler.run_settlement()
|
||||
elif '153000' <= curr_hms <= '160000':
|
||||
for unit in self.units.values():
|
||||
if unit.settler: unit.settler.reset_flag()
|
||||
|
||||
time.sleep(1 if is_trading else 5)
|
||||
except Exception as e:
|
||||
self.logger.error(f"主循环异常: {e}")
|
||||
except:
|
||||
self.logger.error("主循环异常:")
|
||||
self.logger.error(traceback.format_exc())
|
||||
time.sleep(10)
|
||||
|
||||
# ================= 外部接口 =================
|
||||
def process_route(self, strategy_name):
|
||||
strat_cfg = self.config['strategies'].get(strategy_name)
|
||||
unit = self.units.get(strat_cfg.get('qmt_id'))
|
||||
if not unit or not unit.callback or not unit.callback.is_connected: return
|
||||
|
||||
def get_status(self) -> QMTStatus:
|
||||
conn = self.callback.is_connected if self.callback else False
|
||||
return QMTStatus(conn, self.start_time, self.last_heartbeat,
|
||||
self.acc.account_id if self.acc else "Unknown", self.is_running)
|
||||
msg_json = self.r.lpop(f"{strategy_name}_real")
|
||||
if not msg_json: return
|
||||
|
||||
try:
|
||||
data = json.loads(msg_json)
|
||||
# 严格校验消息日期
|
||||
if data.get('timestamp', '').split(' ')[0] != datetime.date.today().strftime('%Y-%m-%d'):
|
||||
return
|
||||
|
||||
def get_positions(self) -> Dict[str, Any]:
|
||||
real = []
|
||||
if self.callback and self.callback.is_connected:
|
||||
pos = self.xt_trader.query_stock_positions(self.acc)
|
||||
if pos:
|
||||
real = [{"code": p.stock_code, "volume": p.volume, "can_use": p.can_use_volume, "value": p.market_value}
|
||||
for p in pos if p.volume > 0]
|
||||
virtual = {s: self.pos_manager.get_all_virtual_positions(s) for s in self.config.get('strategies', {}).keys()}
|
||||
return {"real_positions": real, "virtual_positions": virtual}
|
||||
if data['action'] == 'BUY':
|
||||
self._execute_buy(unit, strategy_name, data)
|
||||
elif data['action'] == 'SELL':
|
||||
self._execute_sell(unit, strategy_name, data)
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_logs(self, lines=50):
|
||||
log_path = os.path.join("logs", f"{datetime.date.today().strftime('%Y-%m-%d')}.log")
|
||||
if not os.path.exists(log_path): return ["今日暂无日志"]
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
return [l.strip() for l in f.readlines()[-lines:]]
|
||||
def _execute_buy(self, unit, strategy_name, data):
|
||||
strat_cfg = self.config['strategies'][strategy_name]
|
||||
# 1. 槽位校验
|
||||
if data['total_slots'] != strat_cfg['total_slots']:
|
||||
self.logger.error(f"[{strategy_name}] 信号槽位({data['total_slots']})与配置({strat_cfg['total_slots']})不符")
|
||||
return
|
||||
|
||||
# 2. 持仓数检查
|
||||
if self.pos_manager.get_holding_count(strategy_name) >= strat_cfg['total_slots']:
|
||||
return
|
||||
|
||||
try:
|
||||
asset = unit.xt_trader.query_stock_asset(unit.acc_obj)
|
||||
# 计算该终端的总槽位之和
|
||||
terminal_strategies = self.get_strategies_by_terminal(unit.qmt_id)
|
||||
total_slots = sum(self.config['strategies'][s]['total_slots'] for s in terminal_strategies)
|
||||
|
||||
if not asset or total_slots <= 0: return
|
||||
|
||||
# 3. 资金等权分配 (基于该终端总资产)
|
||||
total_equity = asset.cash + asset.market_value
|
||||
target_amt = total_equity / total_slots
|
||||
actual_amt = min(target_amt, asset.cash * 0.98) # 预留手续费滑点
|
||||
|
||||
if actual_amt < 2000:
|
||||
self.logger.warning(f"[{strategy_name}] 单笔预算 {actual_amt:.2f} 不足 2000 元,取消买入")
|
||||
return
|
||||
|
||||
# 4. 价格与股数
|
||||
offset = strat_cfg.get('execution', {}).get('buy_price_offset', 0.0)
|
||||
price = round(float(data['price']) + offset, 3)
|
||||
vol = int(actual_amt / (price if price > 0 else 1.0) / 100) * 100
|
||||
|
||||
if vol < 100: return
|
||||
|
||||
oid = unit.xt_trader.order_stock(unit.acc_obj, data['stock_code'], xtconstant.STOCK_BUY,
|
||||
vol, xtconstant.FIX_PRICE, price, strategy_name, 'PyBuy')
|
||||
if oid != -1:
|
||||
unit.order_cache[oid] = (strategy_name, data['stock_code'], 'BUY')
|
||||
self.pos_manager.mark_holding(strategy_name, data['stock_code'])
|
||||
self.logger.info(f"√√√ [{unit.alias}] {strategy_name} 下单买入: {data['stock_code']} {vol}股 @ {price}")
|
||||
except:
|
||||
self.logger.error(traceback.format_exc())
|
||||
|
||||
def _execute_sell(self, unit, strategy_name, data):
|
||||
v_vol = self.pos_manager.get_position(strategy_name, data['stock_code'])
|
||||
if v_vol <= 0: return
|
||||
|
||||
real_pos = unit.xt_trader.query_stock_positions(unit.acc_obj)
|
||||
rp = next((p for p in real_pos if p.stock_code == data['stock_code']), None) if real_pos else None
|
||||
can_use = rp.can_use_volume if rp else 0
|
||||
|
||||
# 取虚拟持仓和实盘可用持仓的最小值
|
||||
final_vol = min(v_vol, can_use)
|
||||
if final_vol <= 0:
|
||||
self.logger.warning(f"[{strategy_name}] 卖出拦截: {data['stock_code']} 实盘无可用持仓")
|
||||
return
|
||||
|
||||
try:
|
||||
offset = self.config['strategies'][strategy_name].get('execution', {}).get('sell_price_offset', 0.0)
|
||||
price = round(float(data['price']) + offset, 3)
|
||||
|
||||
oid = unit.xt_trader.order_stock(unit.acc_obj, data['stock_code'], xtconstant.STOCK_SELL,
|
||||
final_vol, xtconstant.FIX_PRICE, price, strategy_name, 'PySell')
|
||||
if oid != -1:
|
||||
unit.order_cache[oid] = (strategy_name, data['stock_code'], 'SELL')
|
||||
self.logger.info(f"√√√ [{unit.alias}] {strategy_name} 下单卖出: {data['stock_code']} {final_vol}股 @ {price}")
|
||||
except:
|
||||
self.logger.error(traceback.format_exc())
|
||||
|
||||
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()]
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
self.logger.info("收到引擎停止指令")
|
||||
for u in self.units.values():
|
||||
u.cleanup()
|
||||
@@ -5,7 +5,7 @@ import random
|
||||
|
||||
##订阅账户
|
||||
# 设置 QMT 交易端的数据路径和会话ID
|
||||
min_path = r"D:\QMT\国金证券QMT交易端\userdata_mini"
|
||||
min_path = r"C:\\QMT\\中金财富QMT个人版交易端\\userdata_mini"
|
||||
session_id = int(random.randint(100000, 999999))
|
||||
|
||||
# 创建 XtQuantTrader 实例并启动
|
||||
@@ -22,7 +22,7 @@ else:
|
||||
exit()
|
||||
|
||||
# 设置账户信息
|
||||
account = StockAccount('8886100517')
|
||||
account = StockAccount('8176081580')
|
||||
|
||||
# 订阅账户
|
||||
res = xt_trader.subscribe(account)
|
||||
@@ -30,7 +30,3 @@ if res == 0:
|
||||
print('订阅成功')
|
||||
else:
|
||||
print('订阅失败')
|
||||
|
||||
|
||||
download_history_data('000001.SZ', '1m', start_time='20251201', end_time='')
|
||||
print(get_market_data(stock_list=['000001.SZ'], period='1m', start_time='20251201', end_time=''))
|
||||
|
||||
68
qmt/run.py
68
qmt/run.py
@@ -1,50 +1,58 @@
|
||||
# coding:utf-8
|
||||
"""
|
||||
QMT交易系统启动器
|
||||
用于直接运行,避免包导入问题
|
||||
QMT多终端交易系统启动器
|
||||
版本:V2.0 (Multi-Terminal Edition)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import uvicorn
|
||||
|
||||
# 将当前目录添加到Python路径
|
||||
# 将当前目录添加到Python路径,确保模块导入正常
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
# 导入模块
|
||||
from qmt_engine import QMTEngine
|
||||
# 导入升级后的多终端管理器
|
||||
from qmt_engine import MultiEngineManager
|
||||
from api_server import create_api_server
|
||||
import threading
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数 - 启动QMT交易引擎和API服务器"""
|
||||
print(">>> 系统正在启动...")
|
||||
"""主函数 - 启动多终端QMT交易引擎管理中心和API服务器"""
|
||||
# 强制设置环境变量,确保Python在Windows控制台输出不因编码崩溃
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
# 创建QMT引擎实例
|
||||
engine = QMTEngine()
|
||||
print("==================================================")
|
||||
print(" QMT Multi-Terminal System Starting... ")
|
||||
print("==================================================")
|
||||
|
||||
# 1. 获取多终端管理器单例
|
||||
manager = MultiEngineManager()
|
||||
|
||||
try:
|
||||
# 初始化引擎
|
||||
engine.initialize('config.json')
|
||||
print("QMT引擎初始化成功")
|
||||
# 2. 初始化引擎(加载配置、连接Redis、初始化各终端执行单元)
|
||||
manager.initialize('config.json')
|
||||
print("Done: Multi-Manager initialized successfully.")
|
||||
except Exception as e:
|
||||
print(f"QMT引擎初始化失败: {e}")
|
||||
print(f"Error: System initialization failed: {repr(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# 启动交易线程
|
||||
trading_thread = threading.Thread(target=engine.run_trading_loop, daemon=True)
|
||||
# 3. 启动全局监控与交易路由主循环线程
|
||||
# 该线程负责:终端健康检查、断线重连、消息路由、收盘结算
|
||||
trading_thread = threading.Thread(target=manager.run_trading_loop, name="MainTradeLoop", daemon=True)
|
||||
trading_thread.start()
|
||||
print("交易线程启动成功")
|
||||
print("Done: Global trading loop thread started.")
|
||||
|
||||
# 创建API服务器
|
||||
app = create_api_server(engine)
|
||||
print("API服务器创建成功")
|
||||
# 4. 创建适配多终端的API服务器
|
||||
app = create_api_server(manager)
|
||||
print("Done: API server created with multi-terminal support.")
|
||||
|
||||
# 启动Web服务
|
||||
print(">>> Web服务启动: http://localhost:8001")
|
||||
# 5. 启动Web服务
|
||||
print(">>> Web Dashboard: http://localhost:8001")
|
||||
try:
|
||||
# 建议关闭 access_log 以减少控制台刷屏
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
@@ -53,12 +61,10 @@ def main():
|
||||
access_log=False
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n>>> 正在关闭系统...")
|
||||
engine.stop()
|
||||
print(">>> 系统已关闭")
|
||||
|
||||
print("\n>>> Shutdown signal received. Closing terminals...")
|
||||
manager.stop()
|
||||
print(">>> System safely closed.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 使用 -u 参数运行是最佳实践: python -u run.py
|
||||
# 但这里也在代码里强制 flush 了
|
||||
main()
|
||||
# 最佳实践:使用 python -u run.py 运行以获得实时日志输出
|
||||
main()
|
||||
@@ -21,13 +21,13 @@ title QMT 自动化交易系统 [监控中]
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
:: 优化日期获取逻辑,防止由于区域设置导致的非法文件名字符
|
||||
for /f "tokens=1-3 delims=-/ " %%a in ("%date%") do (
|
||||
set "Y=%%a"
|
||||
set "M=%%b"
|
||||
set "D=%%c"
|
||||
)
|
||||
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%\launcher_%TODAY%.log"
|
||||
set "LOG_FILE=%LOG_DIR%\%TODAY%.log"
|
||||
|
||||
cls
|
||||
echo ==================================================
|
||||
|
||||
Reference in New Issue
Block a user