做过聚宽量化的人,一旦决定往 QMT 迁移,通常都是因为同一个理由:聚宽的在线研究环境已经装不下越来越复杂的实盘需求了。我自己也一样,回测在聚宽跑得挺舒服,但真到要上实盘、要处理盘中分钟级信号、要接入自己的风控和缓存时,聚宽那种“网页端跑策略”的封闭感就非常明显。QMT 的好处是本地运行、行情快、支持直接对接券商柜台,但代价是很多坑得自己踩。
这篇文章不讲怎么安装 QMT,也不讲怎么把一个策略原样搬过去,而是把我在迁移过程中遇到的、以及帮朋友排查过的高频问题整理成 5 个细节。尤其是 Redis 连接异常那个部分,我在生产环境里至少被坑过三回,这次一次性说清楚。
1. 迁移前想清楚:两个平台的底层逻辑差异
很多人以为聚宽到 QMT 只是换个 API 名字,把 get_price 改成 xtdata 就行。真这么想,后面会相当难受。聚宽跑策略是一套“在线托管”的模式:你写完策略,聚宽负责定时调度、数据推送、下单执行,你基本不需要关心进程生命周期。QMT 则完全不同,它本质是一个安装在本地或服务器上的行情交易终端,策略代码通过内置 Python 解释器跑在终端进程里,或者通过 miniQMT 以独立进程方式连接终端运行。
这个差异带来三个直接影响:
第一,环境是本地化的。你的第三方库依赖、Python 版本、系统权限全部由自己负责。聚宽里直接import jqdata就有的数据,QMT 里要靠xtquant.xtdata从本地数据服务拉,甚至要先下载数据。
第二,策略的执行入口变了。聚宽是框架自动调用initialize、handle_data。QMT 的 Python 策略需要自己写死循环或依赖框架事件,很多人第一次写while True: pass时才发现睡着了终端会断开。
第三,错误处理从“日志面板”变成了“找进程崩溃原因”。QMT 的 Python 进程如果抛异常,终端界面常常只给一句报错,或者干脆没有任何提示,只在 console 里留下一段 traceback。这就逼着我们必须自己补日志、补异常捕获、补进程守护。
理解这三点后,再开始动代码。下面 5 个细节,是我认为迁移路上最值得提前知道的。
2. 细节一:QMT 安装环境依赖,Python 版本和第三方库最容易卡住
QMT 内置的 Python 版本通常比较旧,不同券商版本可能从 3.6 到 3.8 不等。你本地写好的策略用 pandas 2.0 没问题,但 QMT 内置环境可能只支持 pandas 1.3 甚至更老。很多第三方库的编译版本在这个老解释器上压根装不上,或者装上了又和 QMT 自带的库冲突。
我用过一个券商版本,内置 Python 3.7.9,numpy 已经是 1.21,但再往上就装不上,因为 QMT 的可执行环境里某些动态库是静态链接的。这个阶段最典型的报错是Could not find a version that satisfies the requirement pandas==2.0.3,或者装完了 discover 后ImportError: Something went wrong。
实操建议是:先搞清楚你手里的 QMT 内置 Python 版本和 pip 路径,再决定策略代码的兼容线。
# 在QMT终端的Python环境里执行 import sys print(sys.version) # 查pip路径 import subprocess import sys subprocess.check_call([sys.executable, '-m', 'pip', 'list'])不要用你电脑上的全局 Python 去装库,更不要用 conda 默认环境去装 QMT 插件。我见过有人把 miniconda 的库目录直接拷进 QMT 的site-packages,结果终端连启动都失败,最后只能重装。
正确的做法有两种:
一种是用 QMT 终端自带的python可执行文件直接执行-m pip install,但要注意这个 Python 可能不在 PATH 里。一般在 QMT 安装目录的.\bin.x64\python\python.exe,不同版本路径略有差异。
另一种是我现在更推荐的方式:如果允许,使用 miniQMT 的独立 Python 模式。也就是不依赖 QMT 终端界面,直接用你自己本地的 Python 环境(推荐 conda,Python 3.8 或 3.9)连接 QMT 交易服务。这样第三方库随便装,版本随意,和普通 Python 项目没有区别。前提是你的券商开通了 miniQMT 或“QMT 极简模式”的权限。
这个细节最大的坑在于:你辛辛苦苦在本地环境跑通了的策略,复制到 QMT 内置环境后,可能因为一个dataclasses或typing版本问题直接无法 import。所以迁移第一步,就应该先在目标环境里跑一个最小依赖测试脚本,把 pandas、numpy、xtquant、redis、sqlalchemy 这些核心依赖全部 import 一遍,再往下深入。
3. 细节二:数据接口差异与复权陷阱
聚宽的数据接口非常顺手,get_price、attribute_history几个函数几乎覆盖所有场景。QMT 里对应的是xtquant.xtdata,这个模块既做行情数据服务,也做本地数据下载。表面看都能拿到历史K线,实际差异不少。
第一个坑是返回值格式。聚宽默认给你 pandas DataFrame,索引是时间,列是 open/close/high/low/volume/amount。QMT 的get_market_data_ex返回的是 dict,key 是股票代码,value 是每个字段的 list,甚至不是 DataFrame。直接拿聚宽的逻辑来跑,大概率在列名上就报错。
第二个坑是复权方式。聚宽get_price参数里有 fq='pre' 做前复权,QMT 需要额外调用xtdata.get_market_data前先设置复权参数,或下载除权除息信息。这个细节不处理,回测和实盘会差很多。不过说实话,我建议迁移时先对所有信号和资金曲线做一次“按不复权 + 复权”的敏感性对比,很多因子在复权处理不一致时完全失效。
第三个坑是本地数据需要先下载。QMT 默认只保留近期数据,如果你需要 5 年日线,要么手动在数据管理里下载,要么用代码调用xtdata.download_history_data。我在第一次跑回测时,直接发现 2020 年之前的数据全是 nan,一度以为是接口 bug,后来才意识到是本地数据没下全。
再补充一个与 Redis 间接相关的点:QMT 本地数据读取速度虽快,但在策略进程中频繁通过get_market_data_ex拉取分钟数据时,可能会卡住主线程。我在做盘中信号运算时,会把每日的分钟数据先加载到一个进程内缓存,再用 Redis 存跨进程的中间状态。这个组合在后面的迁移中非常实用。
4. 细节三:交易接口不是拿到账号就能用,类外接和权限是分水岭
QMT 的下单 API 和聚宽最大的不同是:聚宽只要开通了券商权限,模拟盘就能随便跑;QMT 很多券商要求在客户端里手动开通“极速交易”权限,或者单独申请“类外接”接口。所谓“类外接”,是指通过 QMT 终端的外接脚本方式,绕开手动敲单,实现程序化自动交易。如果没开这个权限,xttrader下单时会报类似StkType error或委托失败,但又不会明确告诉你是权限问题。
我当时在模拟盘测试下单一切正常,切到实盘环境后发现无法查资金、无法委托,反复查代码也没有报错,最后才知道是实盘权限没审批。所以迁移之前,先和营业部确认清楚三件事:MiniQMT 权限、类外接权限、行情站点是否支持本地数据服务。
另一个交易接口的坑是下单参数差异。聚宽下单通常传股票代码'000001.XSHE',QMT 用'000001.SZ'这种格式,而且委托价格、成交类型也要先转换。
from xtquant.xttrader import XtQuantTrader from xtquant.xttype import StockAccount from xtquant import xtconstant # 初始化 path = r'D:\QMT\userdata_mini' session_id = 12345 trader = XtQuantTrader(path, session_id) trader.start() trader.connect() account = StockAccount('你的资金账号', 'STOCK') # 下买单示例 order_id = trader.order_stock( account, '000001.SZ', xtconstant.STOCK_BUY, 100, xtconstant.FIX_PRICE, 12.5, '示例策略', )这里有个很不显眼但影响很大的点:连接后必须确认trader.check_connect()返回 0 或 true,否则所有交易接口都会静默失败。很多人在策略启动时没做这个检查,等到盘中才发现账户没连上。我在 QMT 终端里遇到过client is null,一部分原因就是 trader 对象没有成功连接,特别是多进程下重复创建实例时容易触发。
所以交易模块我强烈建议做成一个单例,启动时先连接、再校验账户、最后拉一次资金和持仓,都正常后才允许策略主循环开始运行。
5. 细节四:QMT 终端 client is null,多半是连接时序问题
QMT 的 Python 策略跑在终端进程内部时,经常会出现一个让人摸不着头脑的报错:client is null。这个报错我在聚宽里从没见过,第一次遇到时还以为是环境坏了。排查了很久发现,它其实是终端内部与交易后台之间的连接对象还没准备好,就被策略代码拿去使用了。
典型场景是策略初始化时,在xt_trader.connect()之后立刻开始查资金。如果你只调用了 connect 但没有等待回调返回连接成功状态,某些版本下就会出现client is null。原因很简单,connect 方法本身是异步的,终端后台还没有把 client 实例注入到 Python 层的全局变量里。
解决办法是加等待逻辑:
for i in range(30): # 最多等30秒 if trader.check_connect() == 0: break time.sleep(1)如果是 miniQMT 独立 Python 模式,client is null还可能是因为终端界面没有登录,或者登录超时后自动断开,而 Python 进程还在运行。这时候需要监控此状态并尝试重新连接。
另一个容易出问题的位置是在多线程或定时任务里创建新的 XtQuantTrader 实例。QMT 的 Python 层连接对象不是无限创建的,一个进程内创建多个实例、并且没有正确释放,后面的实例经常拿不到 client。我建议统一封装为:
class TraderClient: _instance = None _trader = None _account = None @classmethod def get(cls): if cls._instance is None: cls._instance = cls() return cls._instance同时把整个策略的交易模块与行情模块分离,不要让行情回调里直接调用交易接口。回调里的异常如果没捕获,往往会把内部连接状态搞坏,下一次再操作就出现 client is null。
6. 细节五:Redis 连接异常处理实战
Redis 在整个迁移里的角色,很多人一开始没想到。我在聚宽里不需要持久化,策略状态都放在全局变量里。但 QMT 本地多进程或多策略并行时,总得有个地方共享信号、缓存中间结果、甚至做分布式锁。Redis 是自然而然的方案。但越自然的方案,踩的坑越深。
先说 Redis 在量化策略里常见用途:存最新行情快照、存策略状态机、做多实例间的锁、发布订阅信号。因为 QMT 本身提供行情服务,Redis 更多是用在策略层,而不是行情层。我自己的架构是:QMT 策略进程把计算好的信号写到 Redis 的 hash 结构里,另一个执行进程或者风控进程从 Redis 订阅信号并执行下单,两个进程通过 Redis 分布式锁保证同一标的不会重复下单。
这个架构很容易因为 Redis 连接异常导致整个策略假死。常见的报错有这几种:
redis.exceptions.ConnectionError: Error while reading from socket: Connection reset by peerredis.exceptions.TimeoutError: Timeout connecting to serverredis.exceptions.ResponseError: WRONGTYPE Operation against a key holding the wrong kind of valueredis.exceptions.MaxClientsError: max number of clients reached
6.1 先从安装和配置开始避坑
Redis 本身在 Windows 上没有官方版本,很多人第一次用都是在 Windows 上下载老外的迁移版,或者从 redis 官网下载 Linux 版再自己编译。这里不是说不能装,而是要注意版本和后端配置。我建议不管是 Windows 还是 Linux 服务器,下载 Redis 时尽量选 6.2 以上版本,因为旧版本在连接池、TLS、ACL 上比较麻烦。
Windows 下最简单的做法是用 WSL 或 Docker 跑一个 Redis 容器:
docker run -d --name redis -p 6379:6379 redis:7-alpine如果你已经在用 Docker,那用docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack还能自带 RedisInsight 可视化管理界面。这个东西比命令行友好太多,排查 key 类型、过期时间、慢查询都很直观。
如果不用 Docker,建议用 Redis Desktop Manager 或者 Another Redis Desktop Manager 来可视化查看。我实际用过 ARDM,免费且跨平台,一旦 Redis 连接异常,它比命令行更容易发现是密码错误、端口不通还是服务没起来。
6.2 连接异常的第一排查顺序
当策略里突然报 Redis 连接异常,整个过程最容易犯的错误是:一上来就改代码、加重试,而不去检查服务本身。排查顺序应该是:服务进程是否在跑 -> 端口是否监听到 -> 密码和 ACL 是否正确 -> 防火墙是否挡了 -> 网络是否通 -> 然后才是代码问题。
Linux 下直接用:
systemctl status redis redis-cli ping ss -lntp | grep 6379Windows 下用services.msc看服务,再用redis-cli.exe -h 127.0.0.1 -p 6379 ping。如果 ping 返回 PONG,服务基本没问题。如果 ping 不通,先看配置里的bind和protected-mode。默认 Redis 只允许127.0.0.1连接,如果你的策略进程在另一台机器,需要修改bind 0.0.0.0或指定内网 IP,同时把protected-mode yes改成 no,但生产环境不建议这样裸奔,最好设置密码和绑定具体 IP。
6.3 连接代码模板:连接池加重试降级
很多人在策略里每次使用 Redis 都直接用redis.Redis(host=...)创建一个新连接,并发一高就会把连接数耗尽,最终出现max number of clients reached。正确做法是使用连接池。
import redis import time from redis.connection import ConnectionPool pool = ConnectionPool( host='127.0.0.1', port=6379, password='your_password', db=0, max_connections=20, decode_responses=True, socket_connect_timeout=5, socket_timeout=5, retry_on_timeout=True, ) def get_redis(): return redis.Redis(connection_pool=pool)注意decode_responses=True,否则你 set 一个字符串再 get 出来是 bytes,容易踩类型不一致的坑。另外socket_connect_timeout和socket_timeout必须显式设置,否则 Redis 服务挂掉时,连接操作可能长时间阻塞,进而卡住策略主循环。
更稳的做法是在关键读写外层加一个带重试和降级的装饰器。比如盘中获取某个信号,如果 Redis 连不上,就直接读本地线程缓存,而不是抛出异常终止策略。
def redis_retry(retries=3, delay=0.5): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(retries): try: return func(*args, **kwargs) except redis.RedisError as e: if i == retries - 1: # 降级到本地缓存或返回默认值 return None time.sleep(delay) return None return wrapper return decorator这个方式能解决绝大多数瞬时抖动问题,但不能解决 Redis 服务持续宕机。持续宕机时,建议策略状态用本地文件或 QMT 内置缓存兜底,等 Redis 恢复后再回放状态。
6.4 数据类型用错的辛酸史
Redis 提供了五种基本数据类型:String、Hash、List、Set、Sorted Set。量化场景里最常用的是 Hash 做多标的状态,Sorted Set 做带时间戳的信号队列,String 做简单缓存。我在迁移时遇到过WRONGTYPE报错,原因是同一个 key,之前用 String 存了涨跌幅,后来代码改成用 Hash 读写,Redis 直接拒绝。
这个问题的本质是 key 管理不统一。建议所有 key 统一加前缀,比如qmt:signal:000001.SZ、qmt:status:account。同时在使用任何 key 前,先通过类型检查或直接用hset写入,不要混用。如果确实需要做数据迁移,可以临时用一个新 key 代替,不要原地replace。
Redis 序列化也是容易踩的坑。直接把一个 pandas DataFrame 塞进 Redis,最常见的办法是 pickle 序列化,但不同 Python 版本的 pickle 兼容性要看版本。更推荐把 DataFrame 转成 JSON 字符串或使用 msgpack。尤其注意 numpy 类型的序列化,纯 JSON 无法处理 np.int64,需要先 cast。
我自己通常是:信号、价格快照用 MessagePack 序列化,状态机用 JSON,成交量缓存用原生字符串加 CSV。宁可多写几行转换代码,也不要让反序列化在盘中抛异常。
6.5 分布式锁别想当然
多进程同时下单时,Redis 分布式锁非常能救急。但 QMT 场景有个特殊性:如果有多个策略实例同时连到同一个 Redis,锁的过期时间必须合理。过期时间太短会导致锁提前释放,重复下单;太长会导致另一个实例卡死等待。我曾经在一次盘中因锁过期时间设了 10 秒,信号处理才 3 秒,结果另一个进程也拿到了锁,造成重复委托。
推荐使用 Redis 官方推荐的 Redlock 思路简化版:获取锁时设置唯一 value(比如 uuid),释放锁时用 Lua 脚本判断 value 再删除,保证不会删除别人的锁。
import uuid lock_key = 'qmt:lock:000001.SZ' lock_value = str(uuid.uuid4()) acquired = r.set(lock_key, lock_value, nx=True, ex=5) if acquired: try: # 执行下单 pass finally: # Lua脚本安全释放 release = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ r.eval(release, 1, lock_key, lock_value)这里还有一个细节:QMT 的行情回调里执行 Redis 操作,如果 Redis 网络抖动,阻塞了回调线程,可能导致行情数据堆积,甚至客户端假死。所以我建议所有 Redis 操作都放在独立线程池里执行,不要把行情回调直接变成 Redis 同步调用。我当时就是图省事直接在回调里 set,结果盘中报了一次连接超时,整个终端卡了十几秒。
6.6 用可视化管理工具辅助定位
排查 Redis 连接异常,光看代码不够。我习惯在服务器上装一个 ARDM 或 RedisInsight,专门看连接数、内存、慢查询日志。很多时候报max number of clients reached,用redis-cli info clients一看,connected_clients 已经到几千,基本都是某个连接没有正确关闭。代码里每redis.Redis()创建一次连接,用完后必须 close 或使用 with 语句。或者干脆统一用连接池,就不会有这种问题。
另外,Redis 的日志也需要打开。Linux 下在 redis.conf 里设置loglevel notice或debug,Windows 版在redis.windows-service.conf中同样设置。一旦出现连接异常,日志会明确告诉你是因为 AUTH 失败、超时还是被保护的 key。没有日志,所有问题都只能靠猜。
这个细节我从聚宽迁移过来后,真正体会到什么叫“第三方组件引入越多,故障点越多”。不过 Redis 一旦配置稳定,它对策略架构带来的灵活性还是很值得的。
7. 迁移后别急着上实盘,先把日志和监控补齐
从聚宽迁到 QMT 后,我最后悔的事情就是没有第一时间把整套日志系统搭好。聚宽有在线日志,QMT 的 console 控制台输出经常被刷新冲掉,特别是策略跑一整天后,你想看昨晚 2 点的报错,控制台早就滚动没了。所以最好在迁移初期就统一用logging写文件,同时定期把关键运行指标写入 Redis,方便外部监控。
我现在的做法是:本地日志按天切分,同时把每次 Redis 连接状态、是否有异常重试、信号产生时间、下单结果等关键节点写入 Redis 的 Stream 或 String 带 TTL。这样就算 QMT 终端崩溃,只要 Redis 没挂,就能从 Redis 里看到策略最后运行到哪一步。
另外,QMT 策略进程偶尔会自己退出,尤其client is null或 Redis 异常没被捕获的极端情况。写一个简单的看门狗脚本,每隔几分钟检查策略进程是否还在,不在就自动拉起,能省掉很多半夜惊醒的麻烦。这个脚本也可以用 Python 的subprocess实现,不要依赖 Windows 计划任务,因为 QMT 终端可能需要登录后才能启动策略。
这些工作看起来琐碎,但却是从聚宽到 QMT 迁移过程里真正拉开体验差距的地方。聚宽是平台帮你兜底,QMT 是你自己当平台运维。准备得越充分,迁移后的实盘才会越省心。