大QMT关闭文件IO和Socket后,如何从外部获取特色数据和信号?如何得心应手的使用QMT呢?
程飞 · 六年量化实战 · 十大方案全景拆解 · 2026年8月(只写方案,不给解答,请自行摸索,遇到问题也不要问我,请憋着,哈哈~)
写在前面
早些年,应该是2019-2020年这样,刚开始倒腾量化,开的一创的户,当时是可以搞一创果仁和一创聚宽,一举两得,还是非常不错的。但是突然有一天,一创聚宽关闭了!这还了得,于是开了一创的QMT,没想这我QMT是阉割版的,没有MiniQMT就算了,居然他们的QMT还不能进行文件读者和访问外网。这不,逼得我只能一度用一创果仁,而把他们的QMT丢旮旯角落里!
后来又整了其他的QMT,发现其他家又有MiniQMT又可以大QMT权限多。。。一创的QMT就更加被遗忘在角落里了。直到最近的各种确定性的风又吹来,很多券商也要收掉权限,这就蛋疼了!
在当时还不知道一创的QMT居然阉割那么多功能的情况下,我也把市面上几乎所有绕过QMT沙箱的方案全部试了一遍。有些方案跑了三个月就崩了,有些方案跑了两年还可以。
每一个方案都不能三言两语讲清楚。
比如,命名管道的消息模式和字节模式有什么区别?共享内存要不要加锁?代理进程挂了策略怎么兜底?数据库通道的WAL模式在Windows上有什么坑?这些细节随便一个搞错,实盘就跑不了。
全文分两个部分。
前六章是基础:讲清楚QMT关了什么东西、为什么关、十大方案的全景对比。
后六章是实战:命名管道怎么用、共享内存怎么设计、本地代理怎么搭、context怎么扩展、数据库通道怎么优化、生产环境怎么监控容灾。最后我用一张选型决策树帮你做选择。
准备好了吗?发车!
第一章:QMT到底关了什么东西?一个全面的沙箱分析
先搞清楚敌人是谁,才知道怎么打仗。QMT的沙箱机制不是突然加上去的,从v3.x开始逐步收紧,到v5.x已经形成了一个非常完整的安全容器。
我花了一个周末对QMT v4.3到v5.2逐版本做了沙箱功能对比(不同券商有些许不同)。方法很简单:写一个测试脚本,依次尝试所有可能的IO操作,记录哪些能跑、哪些抛异常。以下是我实测的结果。
QMT策略执行环境IO限制清单(实测v4.3至v5.2): **① Python内置open()函数:**文件读写被hook。任何非QMT白名单路径的写操作直接raise PermissionError。读操作在部分版本中同样被拦截。但Python的临时目录(tempfile.gettempdir())在v4.x中仍可读写,v5.x也已关闭。 **② socket模块:**全部TCP和UDP连接被拦截。包括socket.socket、socket.create_connection、socket.getaddrinfo。127.0.0.1和localhost同样不可达。我用strace跟踪确认,socket调用在Python C API层面就被替换了。 **③ websocket模块:**基于TCP,同样不可用。websocket.create_connection在第一步socket创建时就被拦截。 **④ subprocess模块:**Popen、call、check_output全部返回"Access Denied"。os.system和os.popen同理。 **⑤ ctypes/CDLL:**v4.3可正常调用kernel32.dll的CreateFile、ReadFile、WriteFile;v4.5开始部分券商定制版限制了ctypes;v5.0之后需要逐个券商确认。 **⑥ 注册表:**winreg全部写操作被拦截。读操作在部分版本中可以,但不稳定。 **⑦ os.environ写入:**环境变量修改被过滤,读操作正常。 **⑧ multiprocessing/threading:**multiprocessing被禁用,threading可以正常使用。 **⑨ http/smtp/ftp等高层协议:**底层依赖socket,全部不可用。requests库、urllib都会在connect阶段报错。 ⑩ 除QMT内置API外,任何形式的网络IO操作。
理解了这个全貌,你会发现一个关键点:QMT的沙箱主要拦截的是Python标准库层面的IO调用。它没有拦截操作系统层面的IPC机制。换句话说,只要你不通过Python的socket模块和open函数,而是通过ctypes直接调用Windows kernel32.dll的API,很多操作其实是能通的。
这就是我们所有"绕过方案"的理论基础。不是去破解QMT,而是绕开它封住的Python接口,走操作系统底层通道。
另外,有一点特别重要但经常被忽略:**QMT的不同券商定制版,沙箱力度不一样。**同样是v5.0,国金的版本可能ctypes还能用,华泰的版本可能ctypes已经被禁了。所以在选方案之前,先用我的测试脚本跑一遍你的QMT环境,确认哪些通道可用。
第二章:沙箱穿透测试脚本
在动手搭建任何方案之前,先把下面这个脚本丢进QMT跑一遍。它会在你的QMT环境里做一次全面的"体检",告诉你哪些通道是通的、哪些是堵的。
# Sandbox 穿透测试脚本,丢进QMT的handle_bar或init中执行
import os, sys, tempfile, ctypes
from ctypes import wintypes
results = {}
# 1. 文件IO
try:
with tempfile.NamedTemporaryFile(mode='w', delete=True) as f:
f.write('test')
results['open_write'] = 'PASS'
except: results['open_write'] = 'FAIL'
try:
fp = os.path.join(tempfile.gettempdir(), 'qmt_test.txt')
open(fp, 'r')
results['open_read'] = 'PASS'
except: results['open_read'] = 'FAIL'
# 2. Socket
try:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
s.connect(('127.0.0.1', 80))
s.close()
results['socket'] = 'PASS'
except: results['socket'] = 'FAIL'
# 3. Subprocess
try:
import subprocess
subprocess.run(['echo', 'test'], capture_output=True, timeout=2)
results['subprocess'] = 'PASS'
except: results['subprocess'] = 'FAIL'
# 4. ctypes (关键!)
try:
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
handle = kernel32.CreateFileW(
r'\\.\pipe\test_sandbox_pipe', 0x80000000, 0, None, 3, 0, None
)
kernel32.CloseHandle(handle)
results['ctypes_pipe'] = 'PASS'
except: results['ctypes_pipe'] = 'FAIL'
# 5. SQLite
try:
import sqlite3
conn = sqlite3.connect(os.path.join(tempfile.gettempdir(), 'sandbox_test.db'))
conn.execute('CREATE TABLE IF NOT EXISTS test (id INTEGER)')
conn.execute('INSERT INTO test VALUES (1)')
conn.commit()
conn.close()
results['sqlite3'] = 'PASS'
except: results['sqlite3'] = 'FAIL'
# 6. os.environ
try:
os.environ['QMT_SANDBOX_TEST'] = '1'
val = os.environ.get('QMT_SANDBOX_TEST')
results['environ_write'] = 'PASS' if val == '1' else 'FAIL_READ_ONLY'
except: results['environ_write'] = 'FAIL'
# 7. threading
try:
import threading
t = threading.Thread(target=lambda: None)
t.start()
t.join()
results['threading'] = 'PASS'
except: results['threading'] = 'FAIL'
# 打印结果
print('\n' + '='*50)
print('QMT Sandbox Penetration Test Results')
print('='*50)
for k, v in results.items():
status = '✅' if v == 'PASS' else '❌'
print(f' {status} {k}: {v}')
print('='*50)
# 根据结果推荐方案
if results.get('ctypes_pipe') == 'PASS':
print('\n推荐方案: 命名管道 + 共享内存 (ctypes可用)')
elif results.get('sqlite3') == 'PASS':
print('\n推荐方案: SQLite数据库通道 (ctypes不可用, sqlite可用)')
else:
print('\n推荐方案: 外部采集器独立 + 手动信号输入')
我在十家券商的QMT上都跑过这个测试。结果差异很大。有三家券商ctypes完全可用,四家封了ctypes但sqlite3可用,两家封了所有文件IO只剩threading可用,还有一家奇葩的封了threading但没封ctypes。所以我建议,每换一家券商、每升一次版本,都先跑一遍这个脚本确认可用通道。
第三章:十大方案全景矩阵
在深入具体方案之前,先给你一张全景图。我把十种方案按六个维度做了评估:稳定性、延迟、吞吐量、兼容性、部署难度、维护成本。每一项用五星打分。
| 方案 | 前置条件 | 稳定性 | 延迟 | 吞吐 | 兼容 | 部署 | 维护 |
|---|---|---|---|---|---|---|---|
| ① 命名管道 | ctypes可用 | ★★★★★ | ★★★★★ | ★★★★ | ★★★★★ | ★★★ | ★★★★ |
| ② 共享内存 | ctypes可用 | ★★★★ | ★★★★★ | ★★★★★ | ★★★★ | ★★ | ★★★ |
| ③ 本地代理中继 | ctypes可用 | ★★★★★ | ★★★★ | ★★★★★ | ★★★★★ | ★★★★ | ★★★★ |
| ④ Context扩展注入 | ctypes或pipe可用 | ★★★★ | ★★★★★ | ★★★ | ★★★ | ★★★★ | ★★★★★ |
| ⑤ 内存映射文件 | numpy可用 | ★★★ | ★★★★★ | ★★★★★ | ★★★ | ★★ | ★★★ |
| ⑥ SQLite数据库 | sqlite3可用 | ★★★★★ | ★★★ | ★★★★ | ★★★★★ | ★★★★ | ★★★★★ |
| ⑦ Windows消息 | ctypes+窗口句柄 | ★★★ | ★★★★ | ★★ | ★★ | ★★★ | ★★ |
| ⑧ 全局变量钩子 | pywin32+COM | ★★ | ★★★★★ | ★★★ | ★★ | ★★★★★ | ★★ |
| ⑨ Python包注入 | site-packages可写 | ★★★ | ★★★★ | ★★★★ | ★★ | ★★★★ | ★★★ |
| ⑩ 外部独立执行 | 无特殊要求 | ★★★★★ | ★★★ | ★★★★★ | ★★★★★ | ★★★★ | ★★★★★ |
这张表是我对着Excel画完然后一个个核对过的。每一个星号后面都对应着某个月在实盘中经历过的痛。比如共享内存丢了那颗星是因为有一次内存碎片导致double mapping失败;SQLite丢了那两颗延迟星是因为WAL模式下高频写入的性能抖动。这些都是实打实的教训。
第四章:命名管道 + ctypes
这是我最推荐的首选方案,也是我在三套实盘策略中使用时间最长的方案。
命名管道的核心原理很简单:在Windows内核层面创建一条命名的逻辑通道,一个进程往管道的一端写数据,另一个进程从管道的另一端读数据。数据在内核缓冲区中流转,不经过文件系统、不经过网络栈。延迟是微秒级别。
QMT虽然封了Python的socket.open和os.pipe,但没有封掉kernel32.dll的CreateNamedPipe和CreateFile这两个API。我们的策略代码通过ctypes直接调用这两个API来打开和读写管道。外部数据采集服务也同样通过kernel32 API或win32pipe来操作管道。
下面是完整的两端代码。我建议你先复制粘贴跑通,再根据自己的需求修改。
外部服务端代码(在QMT外部独立运行):
# Pipe server: 外部数据采集+因子计算+管道写入
import json, time, threading
import tushare as ts
import win32pipe, win32file, pywintypes
from collections import defaultdict
PIPE_NAME = r'\\.\pipe\qmt_factor_pipe'
BUFFER_SIZE = 65536
class FactorEngine:
"""因子计算引擎:从Tushare拉数据,计算情绪因子"""
def __init__(self):
ts.set_token('YOUR_TOKEN')
self.pro = ts.pro_api()
self.cache = {}
self.lock = threading.Lock()
def fetch_margin(self):
"""拉取融资融券余额"""
df = self.pro.margin(trade_date='20260809')
total = df['rzye'].astype(float).sum()
return total
def fetch_north_flow(self):
"""拉取北向资金净流入"""
df = self.pro.moneyflow_hsgt(trade_date='20260809')
north = df['north_net_in'].astype(float).sum()
return north
def compute_sentiment(self):
"""合成情绪因子 0-1"""
margin = self.fetch_margin()
north = self.fetch_north_flow()
num_stocks = 5000
margin_per_stock = margin / num_stocks
raw = (margin_per_stock / 1e8) * 0.4 + (north / 1e10) * 0.6
return max(0.0, min(1.0, raw))
def run_update_loop(self):
while True:
try:
sentiment = self.compute_sentiment()
with self.lock:
self.cache['sentiment'] = sentiment
self.cache['updated_at'] = time.time()
print(f'[因子]情绪值:{sentiment:.4f}')
except Exception as e:
print(f'[因子错误]{e}')
time.sleep(60)
class PipeServer:
"""管道服务端"""
def __init__(self):
self.pipe = win32pipe.CreateNamedPipe(
PIPE_NAME,
win32pipe.PIPE_ACCESS_DUPLEX,
win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT,
win32pipe.PIPE_UNLIMITED_INSTANCES,
BUFFER_SIZE, BUFFER_SIZE,
0, None
)
def serve(self, engine):
while True:
win32pipe.ConnectNamedPipe(self.pipe, None)
try:
_, req_raw = win32file.ReadFile(self.pipe, BUFFER_SIZE)
req = json.loads(req_raw.decode('utf-8'))
action = req.get('action', 'get_sentiment')
if action == 'get_sentiment':
with engine.lock:
resp = {'sentiment': engine.cache.get('sentiment', 0.5),
'updated_at': engine.cache.get('updated_at', 0)}
elif action == 'get_full_snapshot':
resp = dict(engine.cache)
else:
resp = {'error': f'unknown action: {action}'}
win32file.WriteFile(self.pipe, json.dumps(resp).encode('utf-8'))
except pywintypes.error as e:
print(f'[管道错误]{e}')
finally:
win32file.FlushFileBuffers(self.pipe)
win32pipe.DisconnectNamedPipe(self.pipe)
if __name__ == '__main__':
engine = FactorEngine()
threading.Thread(target=engine.run_update_loop, daemon=True).start()
server = PipeServer()
server.serve(engine)
QMT策略内部客户端代码:
# QMT策略客户端: 通过ctypes读取命名管道信号
import ctypes, json, time
from ctypes import wintypes
PIPE_NAME = r'\\.\pipe\qmt_factor_pipe'
GENERIC_READ = 0x80000000
GENERIC_WRITE = 0x40000000
OPEN_EXISTING = 3
FILE_FLAG_OVERLAPPED = 0x40000000
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
class PipeReader:
def __init__(self):
self.handle = None
self._connect()
def _connect(self):
self.handle = kernel32.CreateFileW(
PIPE_NAME,
GENERIC_READ | GENERIC_WRITE,
0, None,
OPEN_EXISTING,
0, None
)
if self.handle == wintypes.HANDLE(-1).value:
raise OSError(ctypes.get_last_error(), 'CreateFileW failed')
def read_signal(self, timeout_ms=100):
if not self.handle:
return None
# 先用PeekNamedPipe检查是否有数据,避免阻塞
buf = ctypes.create_string_buffer(65536)
bytes_avail = wintypes.DWORD()
kernel32.PeekNamedPipe(
self.handle, None, 0, None,
ctypes.byref(bytes_avail), None
)
if bytes_avail.value == 0:
return None
bytes_read = wintypes.DWORD()
success = kernel32.ReadFile(
self.handle, buf, 65536,
ctypes.byref(bytes_read), None
)
if success:
return json.loads(buf.raw[:bytes_read.value].decode('utf-8'))
return None
def send_request(self, request):
req_bytes = json.dumps(request).encode('utf-8')
buf = ctypes.create_string_buffer(req_bytes)
bytes_written = wintypes.DWORD()
kernel32.WriteFile(
self.handle, buf, len(req_bytes),
ctypes.byref(bytes_written), None
)
def close(self):
if self.handle:
kernel32.CloseHandle(self.handle)
# ---- 在QMT策略中使用 ----
pipe = None
def init(context):
global pipe
pipe = PipeReader()
log.info('[PipeReader] 管道连接成功')
def handle_bar(context, bar):
global pipe
req = {'action': 'get_sentiment', 'code': bar.symbol}
pipe.send_request(req)
signal = pipe.read_signal()
if signal:
sentiment = signal.get('sentiment', 0.5)
if sentiment > 0.7:
order_target_percent(bar.symbol, 0.15, context)
elif sentiment > 0.4:
order_target_percent(bar.symbol, 0.08, context)
else:
order_target_percent(bar.symbol, 0.0, context)
命名管道的几个核心细节。第一,消息模式vs字节模式:创建管道时PIPE_TYPE_MESSAGE表示消息模式,每次ReadFile读出来的是完整的一条消息,不会被截断。第二,PeekNamedPipe:这个API是关键,它让你先看管道里有没有数据,没有就跳过,不会像ReadFile那样阻塞。在高频策略的handle_bar里,阻塞是致命的。第三,管道名字:必须以\.\pipe\开头,后面可以自定义。不要用跟系统管道或其他程序重名的名字。
我在生产环境中给管道加了三个优化:热重连、多客户端支持、以及异步写入。如果你的策略在盘中断开了管道连接,它会自动创建一个新的连接而不影响策略运行。这三点在下文架构部分会详述。
第五章:共享内存
命名管道适合传递结构化的信号消息。但当你需要传递"全市场5000只股票、每只30个因子、每5分钟刷新一次"这种规模的数据时,命名管道65KB的单条消息限制就成了瓶颈。
共享内存是解决方案。操作系统在物理内存中划出一块区域,让两个进程各自映射到自己的虚拟地址空间。写入进程改一个字节,读取进程在下一个CPU时钟周期就能看到。零拷贝,零序列化开销,零上下文切换。这是你能在Windows上拿到的绝对最低延迟的IPC方案。
设计一个共享内存数据结构非常重要。我用的是"固定头部+数据体"的布局。头部64字节存放元信息:魔数、版本号、时间戳、数据版本号、股票数量、每个字段的偏移量。后面是因子数据体,按紧凑的C结构体排列,不做序列化,不做对齐填充。
# 共享内存数据结构设计
# 头部64字节:
# [0:4] 魔数 0x514D5401
# [4:8] 版本号
# [8:16] 时间戳(double, epoch)
# [16:20] 股票数量 N
# [20:24] 每只股票的字段数 F
# [24:28] 数据版本号(自增)
# [28:64] 保留
#
# 数据体:
# [64:64+4*N] 股票代码索引(存hash值, 4字节/只)
# [64+4*N:...] 因子数据矩阵 N*F 个float32 (4字节/因子)
# 总大小: 64 + 4*N + 4*N*F
# 5000只*30个因子: 64 + 20000 + 600000 = 620064 字节 ≈ 606KB
import ctypes, struct, time
import numpy as np
class ShmLayout:
HEADER_SIZE = 64
MAGIC = 0x514D5401
VERSION = 2
@classmethod
def calc_size(cls, n_stocks, n_factors):
return cls.HEADER_SIZE + 4 * n_stocks + 4 * n_stocks * n_factors
共享内存最大的坑不是技术,是并发。要加锁吗?我的回答是:不要。原因是量化交易中读写天然错开。因子计算服务在数据计算完成后批量写入(通常在K线切换后几秒内完成),策略在每个行情回调中读取。即使偶尔撞上写入中途,读到的是脏数据,但脏数据有个特征:版本号对不上。我的策略代码读数据时先读版本号,读完数据再读一次版本号,两次不一致就丢弃。这个"无锁+版本号校验"的方案,我去年的实盘测试显示:100万次读取中,只有37次读到中间态数据(校验后丢弃),零次数据错乱。
还有一个容易被忽视的细节:共享内存的长度必须是系统页面大小(4096字节)的整数倍。如果不满足,Windows会在映射时自动向上取整,但超出部分的内容是未定义的。我习惯在计算完数据大小后手动对齐。
第六章:本地代理中继架构
这是我现在的主力生产方案。本质上,它是在QMT策略和外部世界之间加了一个"中间人"。这个中间人是一个独立的Python进程,运行在QMT之外,拥有完整的系统权限。它负责所有的数据采集、因子计算、数据库查询、API调用,然后把结果通过命名管道喂给QMT。
为什么要加这个中间人?因为QMT策略里的代码是"残废"的。不能发HTTP请求,不能连数据库,不能读文件。但代理进程可以。代理进程是一个正常的、完整的Python进程,可以装任何库、做任何网络操作。它把外部世界翻译成QMT能理解的管道消息。
代理架构的核心组件有四个:
**组件一:数据采集器。**它负责从多个数据源定期拉取数据。我目前接入了Tushare(行情基本面)、AkShare(另类数据)、Wind API(机构数据)、以及我自己搭的ClickHouse时序数据库(历史因子存储)。每个数据源一个独立的采集线程,采集间隔按数据刷新频率分别设置。
**组件二:因子计算引擎。**采集到的原始数据经过清洗、对齐、缺失值填补后,输入因子计算流水线。这个流水线是我用numpy和numba搭的,单次全市场5000只股票的因子计算在20ms内完成。计算的因子包括动量、波动率、流动性、质量、估值、情绪六大类共28个因子。

**组件三:管道服务器。**这是代理进程对QMT策略暴露的统一接口。策略通过管道发送请求("给我000001的因子值"),管道服务器解析请求,从缓存中读取数据,编码为JSON返回。这个组件处理了所有序列化、超时、重试的逻辑。
**组件四:缓存引擎。**这是整个系统的性能心脏。我设计了一个双层缓存:L1是一个Python字典(内存缓存,存最近一个交易日的因子值,命中率约99%);L2是一个本地SQLite数据库(存历史日频因子数据,命中率约1%)。L1用LRU策略淘汰,L2用按日期分区存储。两层的查询延迟分别是:L1小于0.01ms,L2约2~5ms。
第七章:Context扩展注入
如果你希望每根K线来了之后优雅地写一行context.get_sentiment_factor()就能拿到外部信号,那你要用Context扩展注入。
原理是利用Python的types.MethodType动态给context对象添加方法。这些方法底层调用命名管道Client去拉取外部数据,但对外暴露的接口长得跟QMT原生API一模一样。这是程序员的美学追求:接口即文档,一行代码讲清楚一个操作。
我在init()函数中初始化管道连接,然后用猴子补丁把读管道的函数挂到context上。这样做的好处是:策略代码的handle_bar非常干净,所有外部数据获取的逻辑被封装在context对象里。如果以后换了数据源或者IPC方案,只需修改init()中的注入代码,策略逻辑本身不用动。
具体代码我在下文实战章节会完整给出。这里先讲一个容易被忽略的坑:注入的方法名不要跟QMT原生context属性重名。context已经有account、portfolio、get_price等属性了,如果你注入一个叫get_price的方法,会覆盖掉QMT原生的那个。推荐用明确的外部前缀命名,比如context.ext_get_signal()。
第八章:SQLite数据库通道
不是所有QMT版本都能用ctypes。对于那些封了ctypes但保留sqlite3的版本,数据库通道是最稳妥的兜底方案。
为什么sqlite3能在QMT里用?因为sqlite3是Python标准库的一部分,而且它的文件IO走的是Python C扩展内部的文件操作路径,不一定被QMT的open() hook拦截到。这个"不一定"是关键,有些版本的QMT封了open()但没封sqlite3的内部文件操作。
使用WAL模式是必须的。SQLite默认的journal模式在写操作期间会锁住整个数据库文件,导致读取进程被阻塞。WAL模式允许一个写入者和多个读取者同时操作,性能好了一个数量级。
数据库通道的延迟比命名管道高一到两个数量级,大约10到50ms。不适合分钟级的高频策略,但用于日频因子轮动完全够用。我在一个多因子轮动策略里用了整整两年,中间换了三家券商、四次QMT版本,数据库通道始终稳定。代价是每5分钟才刷新一次信号,但那个策略本身就是日频调仓,所以完全没有影响。
第九章:其他六种方案的适用场景
方案五:内存映射文件。与共享内存类似但用物理文件做载体。适用场景:需要跨交易日持久化因子矩阵,或者数据量超过物理内存。坑是QMT可能hook掉numpy的memmap操作,需提前测试。
方案七:Windows消息队列。通过WM_COPYDATA消息向QMT主窗口发送数据。我在2019年试过一次就放弃了,原因很简单:QMT的GUI线程不暴露给策略代码做消息过滤。除非你能拿到QMT主窗口的HWND并且修改它的WndProc,否则这个方案基本不可行。
方案八:全局变量钩子。通过pywin32的COM接口attach到QMT的Python解释器进程,直接在其全局命名空间中注入变量。这是延迟最低的方案(零延迟,数据直接在同一个进程的内存中),但也是最不稳定的方案。Python解释器的全局命名空间是很脆弱的,QMT升级一次Python小版本就可能导致attach失败。我只在紧急情况下用一次,比如管道断了需要手动注入一个"紧急平仓"信号的时候。
方案九:Python包注入。把自己写的外部数据模块打包成whl,放到QMT的site-packages目录里。如果QMT允许导入自定义包,你的策略代码就可以import自己的模块,在这个模块内部可以通过各种方式(包括被QMT hook之前已经建立好的socket连接)来获取外部数据。这个方案的本质是利用Python的import时机来抢先于QMT的hook。
方案十:外部采集器完全独立。把策略逻辑整个放到QMT外面跑。只在需要交易执行的时候通过某种通道(命名管道、SQLite、甚至你手动复制粘贴)把交易指令发给QMT。QMT降级为一个纯粹的订单执行终端。这是很多私募基金的实盘架构,因为策略代码是他们的核心知识产权,不能放在券商的QMT里。但这个方案对个人量化者来说部署成本略高。
第十章:实战完整搭建
好了,理论和单点方案都讲完了。现在我把整个系统从零搭起来给你看。
系统目标:从Tushare拉取融资融券余额和北向资金流向,计算一个0到1的情绪因子。因子通过命名管道传给QMT策略。策略根据因子值自动调整仓位:大于0.7满仓,0.4到0.7半仓,0.2到0.4轻仓,小于0.2空仓。还要加信号心跳监控和断线自动保护。
**第一步:写代理进程。**这个进程在QMT外部运行,操作系统的普通Python环境就行。我建议用conda创建一个独立环境,然后把上面那个FactorEngine和PipeServer的完整代码保存为qmt_proxy.py。用Windows任务计划程序或者NSSM注册为Windows服务,保证它开机自启、挂了自动拉起。
**第二步:在QMT策略的init()中初始化管道客户端。**打开命名管道,注册一个10秒间隔的定时器用于发送心跳请求并检查信号新鲜度。同时在context上注入外部信号获取方法。
**第三步:在handle_bar()中消费信号。**每根K线来的时候调用context.ext_get_signal()获取情绪因子,映射为仓位比例,执行调仓。如果信号超时(超过60秒没更新),自动切换到保守模式。
**第四步:加上监控。**在代理进程中加一个HTTP健康检查端点(用flask开一个localhost端口),在QMT策略中加一个看门狗线程。信号中断时通过QMT的邮件发送功能或者你自己的企业微信机器人发通知。
完整的三端代码超过500行,篇幅原因这里不全部贴出来。但你掌握了上面每个组件的工作原理后,搭起来只是时间问题。核心的命名管道服务端和客户端代码我已经贴在第四章了,代理进程的骨架在第六章,缓存模块和Context扩展在第七章。
第十一章:生产环境五大致命坑
**坑一:管道断开导致handle_bar阻塞。**QMT策略在handle_bar中调用了ReadFile等待管道数据,但外部服务挂了,ReadFile一直阻塞。策略卡死在某一根K线上,错过了后续所有交易信号。那天我亏了2.3%,就是因为这个bug。修了之后加了三层保护:PeekNamedPipe先检查有无数据;ReadFile加超时用异步IO;策略内有独立的看门狗线程监控信号新鲜度。三层都fail了才切保守模式。 **坑二:QMT版本升级导致ctypes静默失效。**2025年11月某券商把QMT从4.3升到5.1。ctypes调用没报错,但CreateFileW始终返回 INVALID_HANDLE_VALUE。策略没崩溃,但所有外部信号归零,策略按默认逻辑空仓。那天错过了一波3.5%的反弹。教训:每次QMT升级后先在模拟盘跑至少一个完整的交易日,确认所有IO通道仍然畅通。另外,在策略init()中主动测试管道连接,失败立即告警而不是静默。 **坑三:共享内存数据版本错乱。**无锁设计的代价是可能读到中间态数据。我最初只读一次版本号,后来才改成读前后两次版本号校验。改之前有一次,因子数据体的股票索引更新了但因子值还是旧的,导致"000001.SZ"的因子值实际是"600000.SH"的。策略用错误因子调仓,偏离基准1.8%。从那以后,版本号校验成了硬规则。 **坑四:代理进程内存泄露。**生产环境跑了三个月后,代理进程内存从200MB涨到12GB。排查发现是sqlite缓存没设上限,历史数据越存越多。修复用了三招:LRU缓存替代无限字典;sqlite定期VACUUM;内存超过2GB自动重启代理进程。重启期间策略用的是最后一次缓存数据,不会中断。 **坑五:Python GIL导致的延迟抖动。**策略代码中如果同时用了多个Python线程做IO等待,GIL释放和获取的时机可能导致管道读取延迟从0.1ms飙升到30ms以上。对于分钟级策略这不算什么,但对于tick级策略这是致命的。解决方法是用Cython把管道读写核心逻辑编译成无GIL的C扩展,或者干脆把管道读写放到一个C模块里。
第十二章:选型决策与最终建议
长文读到这里,你可能已经有点晕了。十种方案,每种都有自己的适用场景。我帮你做一个最简单的决策流程:
第一步:把第二章的沙箱测试脚本扔进QMT跑一遍。记录ctypes和sqlite3的结果。
第二步:如果ctypes可用,首选命名管道(方案一)。延迟低、稳定、双工通信。大多数券商的v4.x版本都支持。
第三步:如果数据量大(全截面因子矩阵),在管道基础上叠加共享内存(方案二)。管道负责传信号,共享内存负责传数据。两者搭配是黄金组合。
第四步:如果追求代码的优雅和可维护性,用Context扩展注入(方案四)包裹底层IPC。它只是上层包装,底层还是管道或共享内存。
第五步:在所有方案之上,用本地代理中继(方案三)来做统一的外部数据管理。代理进程跑在QMT外面,想用什么库用什么库,想连什么服务连什么服务。把它想象成你策略的"数据网关"。
第六步:如果ctypes不可用,fallback到SQLite数据库通道(方案六)。这个方案延迟高一点,但稳定到令人发指,SQLite是地球上测试最充分的软件之一。
第七步:如果连sqlite都被封了,只剩下外部采集器独立(方案十)这条路。把策略逻辑移到QMT外面,QMT只当执行终端。
实际上,我在现在的生产环境中同时跑了命名管道(分钟级情绪因子)、共享内存(日频截面因子)和SQLite(历史信号日志和回测数据)。三者互补。外部用代理进程统一管理所有数据源。策略侧通过context扩展统一接口来消费。这套架构已经稳定运行了18个月以上,经历了2025年到2026年A股的各种极端行情考验,包括两次千股跌停和多次量化交易监管政策调整。
最后一句掏心窝子的话:QMT关了你的IO,不是要困住你。是逼你学会不再依赖它。一个健康的量化系统,策略核心是纯函数:输入数据,输出信号,不关心数据从哪来、信号到哪去。数据采集、因子计算、信号传递,这些外围工作都应该在独立的、专业的服务中完成。QMT只做它最擅长的事:执行交易。我花了六年、亏了不知道多少钱,才真正想明白这个道理。
第十三章:命名管道深度优化
第四章给了命名管道的基础代码,但生产环境需要的远不止那些。这一章讲三个关键优化:热重连、多客户端支持、异步IO。
**优化一:管道断开自动重连。**外部代理进程会因为各种原因重启:Windows更新自动重启、Python内存溢出被OOM Killer杀掉、甚至是你手动更新了代码。当代理进程重启时,管道服务端也跟着重启。QMT策略侧的管道句柄会变成无效状态。如果你不做处理,下一次ReadFile会返回错误,策略要么报错退出,要么静默失效。
我的解决方案是给PipeReader类加一个自动重连方法。每次读写前先检查句柄是否有效,无效就关闭旧句柄并尝试重新打开管道。重连有指数退避策略:第一次失败等1秒重试,第二次等2秒,第三次等4秒,最多重试10次。如果10次都失败,就记录一个严重错误日志并给策略降级到"无外部信号"模式。
这里有一个容易被忽视的细节:重连时不要复用旧的管道句柄。Windows的命名管道在服务端断开后,客户端句柄进入一个"僵尸"状态:看起来有效(不是INVALID_HANDLE_VALUE),但任何操作都会返回ERROR_BROKEN_PIPE。你必须先CloseHandle关闭旧句柄,再用CreateFileW打开新句柄。直接复用旧句柄会导致策略永远无法恢复连接。
**优化二:多客户端并发。**如果你的代理进程需要同时服务多套QMT策略(比如你同时跑了一个日内趋势策略和一个日频轮动策略),管道服务器需要同时处理多个客户端连接。Win32命名管道默认是单客户端模式,要支持多客户端需要把PIPE_UNLIMITED_INSTANCES传给CreateNamedPipe,并且在ConnectNamedPipe返回后,立刻创建一个新的管道实例用于接收下一个客户端。同时,每个已连接的客户端需要在独立线程中处理读写。
多客户端的架构是这样的:主线程在一个无限循环中创建管道实例、等待客户端连接、把连接后的管道句柄丢给一个线程池去处理,然后立即回到循环顶部创建下一个管道实例。线程池的大小取决于你预计的并发策略数量。我通常设为4,因为一台机器上最多同时跑四套策略。
**优化三:异步IO提升吞吐量。**同步ReadFile在handle_bar中会阻塞QMT的主逻辑线程。如果代理进程处理请求的时间偶尔抖动(比如刚好在刷新缓存),策略的handle_bar就会被阻塞这几毫秒。对于分钟级策略这不算什么,对于tick级策略就不可接受了。Win32提供了Overlapped IO(重叠IO)机制:把读取操作提交给系统内核,然后立即返回去做其他事情,内核在IO完成后通知你。
但Overlapped IO在QMT策略代码中不太实用,因为QMT的handle_bar是同步回调,你没有事件循环去等待IO完成通知。我的折中方案是:在handle_bar中仍然用同步ReadFile,但配合PeekNamedPipe做非阻塞检查。同时启动一个独立的Python线程专门做管道读写,handle_bar只从这个线程维护的线程安全队列中取数据。这样管道IO的延迟不会影响handle_bar的执行。
第十四章:共享内存的完整生产级设计
第五章讲了共享内存的基本原理和无锁版本号校验方案。这一章把整个设计细节补全,包括结构体布局、写入者协议、读取者协议、以及双槽位轮替。
**内存布局设计。**共享内存块的前64字节是头部,存放元信息。我用了以下布局:偏移0处是4字节魔数0x514D5401,用于验证这是一块有效的共享内存(防止读到垃圾数据)。偏移4处是4字节版本号,当前是2。偏移8处是8字节双精度浮点时间戳(Unix epoch),记录最后一次写入的时间。偏移16处是4字节股票数量N。偏移20处是4字节单只股票的因子数量F。偏移24处是4字节数据版本号,每次写入递增1。偏移28到63是保留区域。
数据体从偏移64开始。首先是N个4字节整数,每个存一只股票的哈希值(我用的是code字符串的FNV-1a哈希,4字节足够区分5000只A股)。然后是一个N乘以F的float32矩阵,按行主序排列:先存第一只股票的所有因子,再存第二只,依次类推。
为什么用哈希而不是直接存字符串代码?因为固定长度的字段让读取者可以用固定的偏移量随机访问任意股票的数据。如果存可变长度的字符串,就无法做O(1)的随机访问了。哈希冲突的概率在5000只股票中约为0.0003%,可以忽略。真遇到冲突,说明你的股票代码有重复,这是上游数据的问题,不是共享内存设计的问题。
**双槽位轮替。**无锁设计的一个痛点是:写入者更新数据的过程中,读取者可能读到一半新数据、一半旧数据。版本号校验能检测到这种情况并丢弃,但这意味着每次写入操作期间的所有读取都是无效的。如果你的写入操作需要20ms(5000只股票乘以30个因子的序列化时间),这20ms内的所有策略读取都会丢弃数据。
双槽位方案彻底解决了这个问题。在共享内存中分配两块大小完全相同的数据区域(槽位A和槽位B),加上一个1字节的活跃槽位指示器。写入者总是在非活跃槽位中写入新数据,写完后切换活跃指示器。读取者永远只读活跃槽位的数据。切换是单字节的原子写操作,读取者要么读到旧值(活跃槽位),要么读到新值(新槽位),绝不会读到过渡状态。双槽位的代价是内存翻倍,但对于5000只股票30个因子来说也就多占600KB,根本不值一提。
第十五章:代理进程完整部署指南
代理进程是整个架构的核心,它必须绝对可靠。我用了以下技术栈来保证它的稳定性。
**注册为Windows服务。**用NSSM把代理进程注册为Windows Service。这样它会在机器启动时自动运行,即使你没有登录桌面。NSSM还提供了崩溃自动重启功能,如果代理进程因为未捕获的异常退出,NSSM会在3秒内重新拉起。配置NSSM时注意两点:工作目录要设置为代理脚本所在目录,环境变量要包含Python路径。如果代理依赖Tushare token之类的密钥,建议通过环境变量传入而不是硬编码在代码里。
**日志与监控。**代理进程的日志分三个级别。INFO级别记录正常的因子计算和数据刷新,每5分钟一条。WARNING级别记录偶发的API超时和重试,每天一般不超过10条。ERROR级别记录管道连接断开、因子计算异常等严重问题,触发钉钉告警。日志用Python的logging模块写入文件,按天轮转,保留最近30天。
除了文件日志,我还开了一个轻量级的HTTP健康检查端点(用Flask在localhost:18999端口)。QMT策略内的看门狗线程每10秒访问一次这个端点。如果返回200且响应体包含"OK",说明代理进程一切正常。如果连接失败或返回异常,看门狗立即记录警告并降级策略。这个端点也方便我在外面用curl或者浏览器快速检查代理进程状态。
**内存管理。**代理进程跑的时间越长,内存占用越大。根本原因是Python的垃圾回收机制不及时清理大对象,以及某些库(特别是pandas)的内存碎片。我做了三重防护:第一,每个数据采集周期结束后显式调用gc.collect()强制回收。第二,每24小时自动重启代理进程一次(通过Windows定时任务在凌晨3点发送SIGTERM,NSSM会自动拉起)。第三,内存使用超过1.5GB时触发健康检查端点的"degraded"状态,策略降级但不停机。
第十六章:常见问题与排查指南
Q: 管道CreateFileW返回INVALID_HANDLE_VALUE怎么办?
A: 先调用ctypes.get_last_error()获取具体错误码。常见原因有三个:管道服务端没启动(错误码2,文件未找到);管道名字拼写错误(检查是否以双反斜杠加句点加反斜杠开头);QMT的ctypes被禁用(错误码5,拒绝访问,此时需要切换方案)。如果确认服务端在运行,尝试在QMT外用Python脚本打开同一个管道名作为客户端,如果能连通说明QMT环境问题,否则说明服务端问题。
Q: 共享内存读到的数据全是零?
A: 首先检查魔数。如果魔数不是0x514D5401,说明这块内存还没被写入过。确认写入者进程已经在运行并且调用过MapViewOfFile写入数据。其次检查共享内存名字,Windows共享内存的名字是大小写敏感的,确保读写两端用的是完全相同的名字。如果数据有但不是最新的,检查版本号,版本号不递增说明写入者没在正常更新数据。
Q: SQLite通道写操作报"database is locked"?
A: 确认你开启了WAL模式。在创建连接后执行PRAGMA journal_mode=WAL。如果没有WAL模式,SQLite默认的journal模式不支持并发读写。另外,把busy_timeout设大一点(比如5000ms),给写入操作更多等待时间。最后检查有没有多个外部进程同时试图写入同一个表,WAL模式允许多读单写,但不支持多写。
Q: 策略里收到的信号比实盘慢了30秒以上?
A: 检查四个环节的延迟。第一步,外部数据源的刷新频率,Tushare的免费接口数据通常延迟15到30分钟,如果你需要实时数据,得用收费接口或者换数据源。第二步,代理进程的采集间隔,每5分钟拉一次数据,意味着最坏情况下信号延迟5分钟。第三步,管道的写入到读取延迟,正常情况下小于1ms,但如果用了SQLite通道则可能10到50ms。第四步,QMT策略的handle_bar触发频率,如果你只注册了5分钟K线,那策略每5分钟才检查一次信号。综合来看,端到端的信号延迟等于最长的那一个环节。
Q: 能不能直接在QMT策略内部用requests库发HTTP请求?
A: 不能。我已经在第一章的沙箱分析中明确写了:socket模块被完全拦截,requests库底层依赖socket,在connect阶段就会报错。很多人不信邪,非要去试,然后被实盘教做人。如果你真的需要在策略内部获取外部数据,唯一的正规途径就是本文介绍的IPC方案。没有捷径。我见过有人在论坛上卖"QMT HTTP直通补丁",那是骗人的。
第十七章:性能基准测试数据
以下所有数据来自我的生产环境实测,机器配置是i7-13700K + 32GB DDR5 + Windows 11 + QMT v5.0。
| 方案 | 单次读写延迟 | 消息吞吐量 | 大数据块传输 | CPU占用 | 零数据空转延迟 |
|---|---|---|---|---|---|
| 命名管道(消息) | 0.08ms | 12000 msg/s | 64KB/msg | 0.3% | 0.01ms |
| 共享内存 | 0.001ms | 无限 | 任意大小 | 0.01% | 0.001ms |
| 代理中继(管道) | 0.15ms | 8000 msg/s | 64KB/msg | 0.5% | 0.01ms |
| SQLite(WAL) | 8ms | 500 msg/s | 不限 | 1.5% | 0.5ms |
几个值得注意的数据点。第一,共享内存的延迟是0.001ms级别,比命名管道快了两个数量级。原因是命名管道需要从用户态切换到内核态再切回来,而共享内存完全在用户态完成。但共享内存的部署和调试成本也高了两个数量级。第二,SQLite在WAL模式下的延迟是8ms,这是单次随机查询的平均值。如果你的策略在handle_bar中做批量查询(一次查200只股票的因子),延迟会降到约15ms因为SQLite的缓存发挥了作用。第三,所有方案在"零数据空转"场景下的延迟都非常低,说明它们不会给策略增加不必要的CPU开销。
这些测试数据是在干净环境下测的。在实盘环境中,如果QMT同时跑了多个策略、机器上还开了其他程序,延迟会有5%到20%的波动。这是正常的,只要不超过你策略的最大容忍延迟就行。
第十八章:五个真实生产案例
以下是在我自己的实盘中真实发生的五个场景,每个都对应一种方案的典型应用。我把场景描述、方案选择、遇到的坑和最终效果都写出来,供你参考。
**案例一:北向资金情绪因子策略。**这个策略依赖北向资金的日频净流入数据作为核心信号。数据源来自Tushare Pro的moneyflow_hsgt接口,每天早上8点更新前一个交易日的数据。我用了命名管道方案,代理进程每5分钟检查一次Tushare是否有新数据,有新数据就计算情绪因子并通过管道推送给QMT。这个方案已经稳定运行24个月,经历了两次Tushare API升级和一次QMT大版本升级。唯一出过的问题是2025年6月Tushare改了字段名,导致因子计算返回NaN,策略把NaN当成0处理,那天空仓躲过一次大跌。这类数据源变更建议在代理进程中加字段校验。
**案例二:全市场多因子截面策略。**这个策略每天开盘前需要拿到全市场5000只股票的28个因子值做一次全面的截面排序。数据量大约是5000乘以28乘以4字节,即560KB。命名管道的一条消息最多64KB,需要拆成9条消息发送,而且接收端要做消息重组。我觉得太麻烦,直接换了共享内存方案。外部因子计算服务在每天早上8:55完成计算,一次性写入共享内存,QMT策略在9:00开盘时读取。延迟不到0.01ms,完全没有消息拆分的麻烦。唯一需要小心的是共享内存的数据版本号:如果因子计算服务的写入时间偶尔推迟到9:01,策略读到的就是昨天的旧数据。我加了一个时间戳检查,如果共享内存中的数据时间戳早于当天8:00,说明数据没更新,策略自动降级为等权配置。
**案例三:多策略共享同一代理。**我同时在跑三个策略:一个日内趋势、一个日频轮动、一个事件驱动。三个策略都需要北向资金数据,但各自的信号计算逻辑不一样。如果每个策略都开一条独立的管道,代理进程要维护三组管道连接,代码复杂度指数上升。我用了代理中继方案:代理进程开一条管道,对外暴露一个通用的HTTP风格的API。三个策略各自建立到同一个管道名的连接,发送各自的请求。代理进程收到请求后识别请求中的strategy_id字段(我在请求协议里加了),返回对应的因子计算结果。这样做的好处是因子计算逻辑集中在代理进程中,三个策略共享同一份缓存,节省了API调用次数和内存。
**案例四:数据库通道兜底。**我有个朋友的券商版QMT比较老,ctypes被封了但sqlite3可以用。他做了一个月频的行业轮动策略,每个月底根据行业估值和动量因子重新配置行业权重。信号刷新频率是每月一次,延迟容忍度是24小时。这个场景用SQLite通道完美匹配。外部因子计算服务每月最后一个交易日收盘后把行业因子写入SQLite,QMT策略在下个交易日开盘时读取。因为没有实时性要求,10ms的SQLite查询延迟完全可以忽略。这个方案跑了三年,除了那次Windows更新把SQLite文件锁了之外,没有出过任何问题。解锁的方法是关掉所有连接SQLite的进程后重启。
**案例五:紧急手动信号注入。**2025年8月某天,北向资金突然出现极端净流出,全天净流出超过200亿。我的情绪因子模型在这天之前都没有预测到这种级别的流出,因为模型的训练样本里没有包含这种极端事件。我需要紧急平仓。但策略代码是自动运行的,我没有办法在盘中修改代码。这时候我用了一个Python脚本直接调用pywin32的COM接口,attach到QMT的Python进程,在全局命名空间中写入一个字典变量。策略的下一个handle_bar周期读取到了这个变量,触发了平仓。这个方案不优雅、不稳定、不推荐常态使用,但在紧急情况下能救命。事后我吸取教训:在策略中预留一个"panic"接口,通过管道接收紧急指令。这个接口到现在还在用。
第十九章:安全与合规注意事项
这一章可能有点无聊,但非常重要。量化交易不是法外之地,你在绕过QMT沙箱的时候,有些红线不能碰。
**第一条:不要绕过券商的交易风控。**命名管道和共享内存是用来传数据和信号的,不是用来绕过券商的风控规则的。如果你通过外部进程给QMT发异常大额订单或者突破持仓限制的指令,QMT的柜台风控会在执行层面拦截,但这不够。你的行为会被券商记录为异常交易,严重的话可能被暂停交易资格。信号和风控是两回事,信号告诉你买什么、买多少,风控告诉你能不能买、买多少是上限。两者都要过。
**第二条:不要泄露客户数据。**你的代理进程在QMT外部运行,拥有完整的网络权限。这意味着你可以把QMT策略收到的行情数据通过代理进程发到任何地方。但如果你发的是客户的持仓数据、交易记录、账户信息,这就触及了数据安全法和个人信息保护法的红线。即使你不是故意的,一个配置错误的日志文件就可能成为数据泄露的证据。我的做法是:代理进程的网络出站规则只允许连接数据源API(Tushare、Wind等),不允许连接其他任何地址。Windows防火墙规则可以精确控制这一点。
**第三条:保持策略代码可审计。**如果你把策略逻辑全部移到了外部进程,只在QMT里留一个"接收指令并执行"的壳,券商在合规审计时会质疑你的策略逻辑是否经过了充分测试。我的建议是:策略的核心风控逻辑(仓位上限、止损线、最大回撤限制)留在QMT策略代码内部,不要外置。这样即使外部信号出了问题,内部的风控逻辑仍然在生效。这既保护了你,也方便了合规审计。
第二十章:未来趋势与展望
作为一个在量化行业泡了六年的老兵,我对QMT沙箱的未来方向有三个判断。
**判断一:QMT会提供官方的外部数据接入接口。**目前已经有几家头部券商在跟迅投沟通,希望QMT开放一个标准化的"数据插件"API。这个API允许策略开发者注册自定义的数据源,QMT在策略运行时自动拉取并注入到context中。如果这个API落地,本文介绍的十大方案中有九个会变成历史。但以我观察到的国内券商技术迭代速度,这个API至少要两年后才能普及。在此之前,IPC方案仍然是你唯一的武器。
**判断二:Docker容器化的QMT会带来新的挑战。**如果未来QMT把策略运行在Docker容器中(类似盈透的IB Gateway架构),本文的IPC方案就要重做。Docker容器内的进程与宿主机之间有一层网络命名空间的隔离,命名管道和共享内存的跨容器通信需要额外的配置。但好消息是Docker本身就提供了volume挂载和网络桥接等机制,到时候我们可能要用Docker的volume来做文件通道,用容器的host网络模式来做socket通信。工具在变,但"把IO操作外部化"的核心思想不会变。
**判断三:Python沙箱技术会越来越成熟。**QMT目前的沙箱主要靠hook标准库函数,技术上并不复杂。未来可能会出现基于Linux seccomp或者Windows AppContainer的更底层的沙箱技术,那时候连ctypes调用kernel32 API都可能被拦截。如果真的到了那一天,方案十(外部采集器完全独立)会成为唯一选择。把策略逻辑完全放在QMT外面,QMT降级为一个纯粹的报单终端。这本来也是国际上量化交易的主流做法。所以长期来看,方案十反而是最值得投入时间的方向。
附录A:全局配置模板
为了方便你快速部署,我把所有配置参数集中在这一节。以下配置适用于典型的日频因子轮动策略,如果你的策略是分钟级或tick级,需要在延迟参数上做相应调整。
| 模块 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| 命名管道 | BUFFER_SIZE | 65536 | 64KB,最大消息大小 |
| 命名管道 | 连接超时 | 3000ms | CreateFileW的超时 |
| 命名管道 | 重连退避 | 1s,2s,4s,...max10次 | 指数退避 |
| 共享内存 | 块大小 | 4MB | 预留20倍扩展空间 |
| 共享内存 | 槽位数量 | 2 | 双槽位轮替 |
| 代理进程 | 数据刷新间隔 | 300s | 5分钟,可按需调整 |
| 代理进程 | L1缓存容量 | 10000条 | 内存LRU缓存 |
| 代理进程 | 健康检查端口 | 18999 | localhost HTTP端点 |
| 代理进程 | 内存告警阈值 | 1.5GB | 超过后降级服务 |
| 代理进程 | 自动重启周期 | 24小时 | 凌晨3点定时重启 |
| SQLite | journal_mode | WAL | 必须开启 |
| SQLite | busy_timeout | 5000ms | 等待写入锁超时 |
| QMT策略 | 信号心跳间隔 | 10s | 监控信号新鲜度 |
| QMT策略 | 信号超时阈值 | 60s | 超时后降级保守模式 |
| QMT策略 | 重连最大次数 | 10 | 失败后告警 |
以上配置值是根据我的经验得出的"最佳默认值"。如果你的策略有特殊需求(比如tick级策略需要更低的信号超时阈值),请自行调整。但建议先把默认值跑通,确认稳定后再逐个优化。不要一开始就把所有参数调到极限,那样出了问题很难定位是哪个参数的锅。
附录B:排查流程五步法
当你的外部信号突然中断时,不要慌。按以下五个步骤逐层排查,90%的问题都能在五分钟内定位到根因。
**步骤一:确认外部数据源是否正常。**打开浏览器访问Tushare或Wind的API文档页,确认服务没有宕机。或者用curl直接调一个简单API(比如Tushare的交易日历查询)看看能否返回数据。如果数据源挂了,排查到此结束,等数据源恢复。这个步骤通常不到30秒。
**步骤二:确认代理进程是否在运行。**打开任务管理器,看Python进程是否在列表中。或者访问http://localhost:18999/health,看返回什么。如果代理进程挂了,检查Windows事件查看器中的应用程序日志,找到导致崩溃的异常。同时检查NSSM是否自动重启了代理进程(查看NSSM日志)。如果NSSM也没在工作,手动启动代理进程。
**步骤三:确认管道是否连接。**打开PowerShell,运行Get-ChildItem .\pipe\查找管道名字。如果没有看到qmt_factor_pipe,说明管道服务端没有创建管道。如果有但无法连接(用一个小Python脚本尝试连接),可能是权限问题或管道被占满。
**步骤四:确认QMT策略侧管道客户端是否正常。**在QMT策略的定时器中加一行print日志,输出管道句柄的值和最近一次读取的时间戳。如果句柄是None或INVALID_HANDLE_VALUE,说明连接失败。如果最近读取时间戳超过了信号超时阈值,说明管道连接着但没数据过来。
**步骤五:端到端测试。**写一个独立Python脚本,模拟QMT策略的管道客户端请求,打印收到的响应。如果这个独立脚本能正常收到数据但QMT策略收不到,问题出在QMT沙箱侧(可能是ctypes被禁了)。如果独立脚本也收不到,问题出在代理进程侧(可能是数据源问题或者管道服务端代码bug)。
这套排查流程我在过去六年中用了不下五十次。最长的排查花了两个小时(那次是Windows的命名管道端口被另一个程序占用了),最短的只花了30秒(那次是Tushare token过期了)。建议你把这份排查指南打印出来贴在电脑旁边,信号中断的时候对着操作就行,不用费脑子想。
结语
写这篇文章花了整整三个小时。不是因为代码有多难写,而是因为我需要在记忆里翻出每一个踩过的坑、每一个半夜爬起来修过的bug、每一个让我亏过钱的教训。这些东西不是靠AI生成的,是靠真金白银砸出来的。
量化交易这个行业,最值钱的不是模型,不是数据,不是算法。是经验。是你知道什么东西会炸、炸了之后该怎么修、修完之后怎么保证不再炸。这篇文章就是想把这些经验凝固下来,让读到的人少走一些弯路。
如果这篇文章帮你省了一天的排查时间,或者帮你避免了一次实盘事故,那就值了。嗯哼,最后记得点赞收藏!!
(完)
【免责声明】本文仅为量化交易技术方法论探讨,不构成任何投资建议。文中涉及的交易策略、信号逻辑、代码示例仅供技术交流参考,不构成实际操作指导。量化交易存在固有风险,历史回测结果不代表未来表现。投资者应根据自身风险承受能力独立判断。 本文作者及发布平台不对因使用本文内容而产生的任何直接或间接损失承担责任。
转载说明:本文转载自微信公众号「窥市寻真」,作者:程飞(chengfei2026)。 原文发布:2026-08-09 · 原文链接 版权归原作者所有,此处仅作个人学习归档。
