This commit is contained in:
2026-06-12 16:25:41 +08:00
parent ef4c1cca32
commit 2d8a0c3bca
23 changed files with 2904 additions and 525 deletions
+260 -28
View File
@@ -2,6 +2,8 @@
QMT 真实交易实现 - 封装 xtquant SDK
"""
import datetime
import os
import subprocess
import threading
import time
import config
@@ -15,6 +17,185 @@ class RealQmtV:
封装 xtquant 的 XtQuantTrader,提供与模拟器一致的接口
"""
# miniQMT 进程名关键字(GUI 壳: XtMiniQmt.exe,交易引擎: miniquote.exe
_QMT_PROCESS_KEYWORDS = ['Qmt', 'qmt', 'QMT', 'miniquote', 'MiniQuote']
@staticmethod
def _discover_qmt_port() -> int:
"""
自动探测 miniQMT 监听端口。
方法1: SDK 内部扫描 (读取配置)
方法2: netstat 找 LISTENING 端口 → 反查所属进程名 → 匹配 QMT 关键字
返回端口号,未找到返回 0。
"""
# ---- 方法1: SDK 内部扫描 ----
try:
from xtquant import xtconn
addrs = xtconn.scan_available_server_addr()
for addr in addrs:
try:
port = int(addr.split(':')[1])
if port:
PrintLog(LogLevel.INFO, f'[端口探测] SDK 扫描发现端口: {port}')
return port
except (ValueError, IndexError):
continue
except Exception as e:
PrintLog(LogLevel.DEBUG, f'[端口探测] SDK 扫描异常: {e}')
# ---- 方法2: netstat → 反向查进程名 ----
try:
# 2a. netstat 找出所有 LISTENING 端口的 PID
pid_ports = {} # pid -> [port, ...]
netstat = subprocess.run(
['netstat', '-ano'],
capture_output=True, text=True, timeout=10
)
for line in netstat.stdout.splitlines():
if 'LISTENING' not in line and 'LISTEN' not in line:
continue
parts = line.split()
if len(parts) < 5:
continue
try:
local_addr = parts[1]
port = int(local_addr.rsplit(':', 1)[-1])
pid = int(parts[-1])
if port > 0:
pid_ports.setdefault(pid, []).append(port)
except (ValueError, IndexError):
continue
if not pid_ports:
PrintLog(LogLevel.DEBUG, '[端口探测] netstat 未找到任何 LISTENING 端口')
return 0
# 2b. 对每个有监听端口的 PID,查进程名是否匹配 QMT
for pid, ports in pid_ports.items():
name = RealQmtV._get_process_name(pid)
if name and any(kw in name for kw in RealQmtV._QMT_PROCESS_KEYWORDS):
port = ports[0]
PrintLog(LogLevel.INFO, f'[端口探测] 发现 QMT 进程: {name} (PID={pid}), 端口: {port}')
# 同时探测 userdata_mini 路径
exe_path = RealQmtV._get_process_exe_path(pid)
if exe_path:
PrintLog(LogLevel.INFO, f'[路径探测] 进程路径: {exe_path}')
found_path = RealQmtV._find_userdata_mini(exe_path)
if found_path:
PrintLog(LogLevel.INFO, f'[路径探测] 发现 userdata_mini: {found_path}')
if found_path != config.miniQMTPath:
PrintLog(LogLevel.INFO, f'[路径探测] 自动修正 miniQMTPath: {config.miniQMTPath} -> {found_path}')
config.miniQMTPath = found_path
# 同时从窗口标题提取资金账号
account = RealQmtV._discover_account()
if account:
if account != config.account_no:
PrintLog(LogLevel.INFO, f'[账号探测] 自动修正 account_no: {config.account_no[-4:]}**** -> {account[-4:]}****')
config.account_no = account
else:
PrintLog(LogLevel.INFO, f'[账号探测] 确认账号: {account[-4:]}****')
return port
except Exception as e:
PrintLog(LogLevel.INFO, f'[端口探测] 进程扫描异常: {e}')
PrintLog(LogLevel.WARNING, '[端口探测] 未能自动发现 miniQMT 端口')
return 0
@staticmethod
def _get_process_name(pid: int) -> str:
"""通过 PID 获取进程名(单个查询,不用扫全量 tasklist)"""
try:
result = subprocess.run(
['tasklist', '/fi', f'PID eq {pid}', '/fo', 'csv', '/nh'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith('INFO:'):
continue
parts = [p.strip('"').strip() for p in line.split('","')]
if len(parts) >= 2:
return parts[0]
except Exception:
pass
return ''
@staticmethod
def _get_process_exe_path(pid: int) -> str:
"""通过 PID 获取进程的可执行文件完整路径"""
try:
result = subprocess.run(
['powershell', '-NoProfile', '-Command',
f'(Get-Process -Id {pid}).Path'],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
)
path = result.stdout.strip()
if path and os.path.isfile(path):
return path
except Exception:
pass
return ''
@staticmethod
def _find_userdata_mini(exe_path: str) -> str:
"""从 QMT 可执行文件路径向上查找 userdata_mini 目录"""
exe_dir = os.path.dirname(exe_path)
# 从 exe 所在目录开始,向上最多 3 层
for _ in range(4):
candidate = os.path.join(exe_dir, 'userdata_mini')
if os.path.isdir(candidate):
return candidate
parent = os.path.dirname(exe_dir)
if parent == exe_dir:
break
exe_dir = parent
return ''
@staticmethod
def _discover_account() -> str:
"""
从 XtMiniQmt.exe 的窗口标题中提取资金账号。
标题格式: "8882874667 - 国金证券QMT交易端 2.0.8.300"
返回账号字符串,失败返回空字符串。
"""
try:
# 找到 XtMiniQmt.exe 的 PID
tasklist = subprocess.run(
['tasklist', '/fo', 'csv', '/nh'],
capture_output=True, text=True, timeout=10
)
gui_pid = 0
for line in tasklist.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip('"').strip() for p in line.split('","')]
if len(parts) >= 2 and 'XtMiniQmt' in parts[0]:
gui_pid = int(parts[1])
break
if not gui_pid:
return ''
# 获取窗口标题(PowerShell 输出可能含中文,用 utf-8)
result = subprocess.run(
['powershell', '-NoProfile', '-Command',
f'(Get-Process -Id {gui_pid}).MainWindowTitle'],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
)
title = result.stdout.strip()
if title and ' - ' in title:
account = title.split(' - ')[0].strip()
if account.isdigit():
return account
except Exception:
pass
return ''
@staticmethod
def _to_plain_code(stock_code: str) -> str:
"""将 xtquant 格式 '600519.SH' 转换为数据库格式 '600519'"""
@@ -33,15 +214,6 @@ class RealQmtV:
# fallback: try both, prefer SH
return f'{code}.SH'
@staticmethod
def _strip_code_suffixes(datas: dict) -> dict:
"""批量去除 xtquant 数据中的代码后缀"""
result = {}
for code, tick in datas.items():
result[code] = tick
if '.' in code:
result[code.split('.')[0]] = tick
return result
def __init__(self) -> None:
self.inited = False
self.connected = False
@@ -66,9 +238,11 @@ class RealQmtV:
self.mini_qmt_path = config.miniQMTPath
self.account = StockAccount(config.account_no, 'STOCK')
PrintLog(LogLevel.INFO, f'[QMT] 初始化: path={self.mini_qmt_path}, account={config.account_no[-4:]}****')
# 创建 XtQuantTrader 实例
session_id = int(time.time()) % 10000
PrintLog(LogLevel.INFO, f'[QMT] 创建 XtQuantTrader, session={session_id}')
self.xt_trader = XtQuantTrader(self.mini_qmt_path, session_id)
# 注册回调 — xtquant 只接受一个回调对象,会在上面调用 on_xxx 方法
@@ -81,30 +255,62 @@ class RealQmtV:
PrintLog(LogLevel.ERROR, f'- [失败] QMT 初始化: {e}')
def connect(self) -> bool:
"""连接 MiniQMT"""
"""连接 MiniQMT,失败自动探测端口并重试"""
if not self.inited:
PrintLog(LogLevel.ERROR, '- [失败] QMT 未初始化')
PrintLog(LogLevel.ERROR, '[QMT] 连接失败: 未初始化')
return False
try:
# 启动 trader 线程
_connect_errors = {
0: '成功',
-1: '一般错误(miniQMT 可能未启动)',
-2: 'miniQMT 未运行(请先启动极简QMT)',
-3: '连接超时',
}
def _do_connect() -> int:
self.xt_trader.start()
# 建立连接
connect_result = self.xt_trader.connect()
PrintLog(LogLevel.INFO, '[QMT] xt_trader.start() 完成')
PrintLog(LogLevel.INFO, '[QMT] 正在连接 miniQMT...')
return self.xt_trader.connect()
try:
# 尝试默认连接
PrintLog(LogLevel.INFO, '[QMT] 尝试默认方式连接...')
connect_result = _do_connect()
# 失败则自动探测端口并重试
if connect_result != 0:
PrintLog(LogLevel.INFO, '[QMT] 默认连接失败,启动端口自动探测...')
discovered_port = self._discover_qmt_port()
if discovered_port > 0:
PrintLog(LogLevel.INFO, f'[QMT] 探测到端口 {discovered_port},尝试连接...')
try:
from xtquant import xtdata
xtdata.connect(ip='127.0.0.1', port=discovered_port)
PrintLog(LogLevel.INFO, f'[QMT] xtdata 连接成功 (端口: {discovered_port})')
except Exception as e:
PrintLog(LogLevel.ERROR, f'[QMT] xtdata 连接失败 (端口: {discovered_port}): {e}')
return False
connect_result = _do_connect()
else:
PrintLog(LogLevel.WARNING, '[QMT] 端口自动探测未找到 miniQMT 进程')
result_desc = _connect_errors.get(connect_result, f'未知({connect_result})')
PrintLog(LogLevel.INFO, f'[QMT] connect() 返回: {connect_result} ({result_desc})')
if connect_result == 0:
# 订阅账户 (传入 StockAccount 对象而不是 account_id 字符串)
PrintLog(LogLevel.INFO, f'[QMT] 订阅账户...')
self.xt_trader.subscribe(self.account)
# 等待回调
time.sleep(1)
PrintLog(LogLevel.INFO, '[QMT] 订阅完成')
self.connected = True
self.startMarketDataSubscription()
PrintLog(LogLevel.INFO, f'- [成功] 真实交易连接成功 (账号: {config.account_no})')
PrintLog(LogLevel.INFO, f'[QMT] 连接成功 (账号: {config.account_no[-4:]}****)')
return True
else:
PrintLog(LogLevel.ERROR, f'- [失败] 连接失败, 返回码: {connect_result}')
PrintLog(LogLevel.ERROR, f'[QMT] 连接失败: {result_desc}')
return False
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [失败] 连接异常: {e}')
PrintLog(LogLevel.ERROR, f'[QMT] 连接异常: {e}')
return False
def getAllPositions(self) -> dict:
@@ -144,18 +350,41 @@ class RealQmtV:
return None
def queryPendingOrder(self, stock_code: str, tag: str) -> list:
"""查询挂单"""
"""查询挂单(过滤已撤/废单)"""
if not self.connected:
return []
try:
orders = self.xt_trader.query_stock_orders(self.account)
# 过滤已撤(54)和废单(57),避免策略误判"已有挂单"跳过下单
_CANCELED = {54, 57}
return [o for o in orders
if self._to_plain_code(getattr(o, 'stock_code', '')) == stock_code and
(tag is None or getattr(o, 'strategy_name', None) == tag)]
(tag is None or getattr(o, 'strategy_name', None) == tag) and
getattr(o, 'order_status', 0) not in _CANCELED]
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询挂单失败] {e}')
return []
def queryTodayOrders(self) -> list:
"""查询当日所有委托"""
if not self.connected:
return []
try:
return list(self.xt_trader.query_stock_orders(self.account))
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询委托失败] {e}')
return []
def queryTodayTrades(self) -> list:
"""查询当日所有成交"""
if not self.connected:
return []
try:
return list(self.xt_trader.query_stock_trades(self.account))
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询成交失败] {e}')
return []
def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name):
"""异步下单"""
if not self.connected:
@@ -328,7 +557,8 @@ class RealQmtV:
# xtquant 返回 "600519.SH" 格式 keyUI 使用纯代码 "600519"
# 构建同时包含两种 key 的数据确保匹配
eBus.event_bus.publish(eBus.MarketDataUpdate, self._strip_code_suffixes(datas))
# 直接发布 xtquant 原始数据(代码带 .SH/.SZ 后缀)
eBus.event_bus.publish(eBus.MarketDataUpdate, datas)
def _market_data_watchdog(self):
"""行情活跃监控 — 超过 30 秒无数据则标记市场不活跃"""
@@ -349,10 +579,10 @@ class RealQmtV:
# ---- xtquant 回调处理 (xtquant 通过回调对象调用 on_xxx 方法) ----
def on_connected(self):
print(datetime.datetime.now(), '真实 QMT 连接成功')
PrintLog(LogLevel.INFO, f'[QMT] on_connected: 真实 QMT 连接成功 {datetime.datetime.now()}')
def on_disconnected(self):
print(datetime.datetime.now(), '真实 QMT 连接断开')
PrintLog(LogLevel.WARNING, f'[QMT] on_disconnected: 真实 QMT 连接断开 {datetime.datetime.now()}')
def on_stock_order(self, order):
self._pending_orders.append(order)
@@ -364,11 +594,13 @@ class RealQmtV:
eBus.event_bus.publish(eBus.MarketOrderCreated, response)
def on_order_error(self, order_error):
print(f"\n真实委托报错回调: order_id={order_error.order_id}, error_id={order_error.error_id}, error_msg={order_error.error_msg}, remark={order_error.order_remark}")
PrintLog(LogLevel.ERROR,
f'[QMT] 委托报错: order_id={order_error.order_id}, error_id={order_error.error_id}, '
f'error_msg={order_error.error_msg}, remark={order_error.order_remark}')
eBus.event_bus.publish(eBus.MarketOrderError, order_error)
def on_account_status(self, status):
print(datetime.datetime.now(), status)
PrintLog(LogLevel.INFO, f'[QMT] on_account_status: {datetime.datetime.now()} {status}')
qmtv = RealQmtV()