← 返回首页

QMT 内置 Python 实战规范(上):环境、生命周期与下单 📈

最近做了一件有点笨但很值的事:把手上五个能实盘跑通的 QMT 策略、两份券商手册、官网 innerApi 十几页文档,再加上在真实客户端里跑的六组探针脚本,全部交叉对照了一遍。

先说最反直觉的结论:官方文档是删减版,最该信的是你自己跑通的代码。

这篇讲「环境、运行机制和下单」,下一篇讲「品种判定、交易日判断、行情取数、交易字段,以及怎么让客户端自己回答语法问题」。

配图

一、权威性顺序:把官网放到最后

写 QMT 代码遇到文档互相打架时,按这个顺序裁决:

CODE
+--------------------+--------------------------------------------------+
| 优先级             | 来源                                             |
+--------------------+--------------------------------------------------+
| 1(最高)          | 你自己实盘跑通的代码                             |
+--------------------+--------------------------------------------------+
| 2                  | 券商 / 迅投的本地手册(PDF、DOCX)               |
+--------------------+--------------------------------------------------+
| 3(最低)          | 官网 dict.thinktrader.net/innerApi               |
+--------------------+--------------------------------------------------+

官网为什么排最后?因为它删掉了四样东西:

  • is_typed_stock(判品种,附完整证券分类表)
  • can_cancel_order(判能否撤单)
  • do_order(立刻发出上一根 K 线的信号)
  • run_time 的第 4 个参数 market
这四样只存在于券商手册里。所以 「官网查不到」不等于「不能用」,先翻手册再下结论。

二、先搞清你在什么 Python 里写代码

这点比想象中重要 —— QMT 内置的是 Python 3.6.8:

CODE
+--------------------+--------------------+------------------------------+
| 项                 | 实测值             | 影响                         |
+--------------------+--------------------+------------------------------+
| Python             | 3.6.8              | 不能用 3.7+ 语法             |
+--------------------+--------------------+------------------------------+
| pandas             | 0.22.0             | 极旧,禁 pandas 1.x API      |
+--------------------+--------------------+------------------------------+
| numpy              | 1.19.1             | 可用                         |
+--------------------+--------------------+------------------------------+
| TA-Lib             | 可用               | 直接 import 就能用           |
+--------------------+--------------------+------------------------------+

用不了的:海象运算符 :=、dataclasses、list[str] 这种泛型下标、f-string 的 = 调试语法。
能用的:f-string 本身、类型注解、% 格式化。

一个容易踩的点:pandas 0.22 太老了,Series[-1] 的位置索引回退居然还生效,所以 get_market_data(...)['close'][-1] 这种写法能跑。换到新 pandas 上会直接报错 —— 这是版本红利,不是正确写法。新代码建议改用 get_market_data_ex,它的返回结构固定为 {code: DataFrame}。

三、运行机制怎么选

QMT 有三套驱动机制,选错是最常见的设计级错误:

CODE
+------------------------+----------------------------+--------------------------+
| 机制                   | 触发方式                   | 适用场景                 |
+------------------------+----------------------------+--------------------------+
| handlebar(C)           | 历史 K 线逐根 + 盘中 tick  | 回测;需要逐 K 线语义的  |
|                        |                            | 实盘                     |
+------------------------+----------------------------+--------------------------+
| ContextInfo.run_time   | 固定时间间隔               | 盘中定时判断(实盘首选) |
+------------------------+----------------------------+--------------------------+
| subscribe_quote        | 订阅品种 tick 到达         | 需要比 3 秒更快、或只关  |
|                        |                            | 心少数品种               |
+------------------------+----------------------------+--------------------------+

几条要记住的边界:

  • handlebar 的股票 tick 大约 3 秒一个,频率受主图品种影响。想更快只能用订阅。
  • run_time 在回测里完全不触发。 回测必须走 handlebar,否则你会以为策略没信号。
  • run_time 没有停止方法,随策略一起结束;需要精细控制就用 schedule_run。
  • 订阅有数量上限(非 VIP 受限),而且超限不报错,客户端会拿前值填充。
实盘混合场景的推荐组合是:init 里订阅 + handlebar 空实现 + run_time 做状态刷新与兜底。

定时器有两种 API:

PYTHON
# 旧 API —— 实盘代码大量在用,可靠
C.run_time("my_func", "5nSecond", "2000-01-01 00:00:00", "SH")
#                                        ^ 传历史时间 = 立刻启动
# 新 API —— 支持分组、可取消、可限定次数
import datetime as dt
tid = C.schedule_run(on_timer, '20231231235959', -1, dt.timedelta(minutes=1), 'my_timer')
C.cancel_schedule_run('my_timer')     # 按组取消
C.cancel_schedule_run(tid)            # 按号取消

period 单位是 nMilliSecond / nSecond / nDay。注意部分周期在首次运行前会先等一个 period,别把第一次触发的时间算错。

生命周期函数一共这几个:

PYTHON
init(C)          # 启动一次。执行完之前部分接口还不可用
after_init(C)    # init 之后、handlebar 之前一次
handlebar(C)     # 每根 K 线 / 每个 tick,必须定义
stop(C)          # 策略停止前 —— 此时交易连接已断开,禁止报单/撤单
account_callback / order_callback / deal_callback / position_callback
orderError_callback
                 # 仅实盘模式生效,且必须先在 init 里 C.set_account(账号)

一个容易忽略的点:init 里很多接口还没准备好。 实测 get_trading_dates 在 init 里拿不到东西,要放到 after_init。

四、八条铁律

违反任何一条,代码大概率「能编译但不工作」,而且报错信息毫无指向性。

1. 首行统一写 # -- coding: gbk --

三种写法等价:#coding:gbk / #coding=gbk / # -- coding: gbk --。真正的坑不在写法,而在声明和文件实际字节编码不一致:

CODE
+----------------------------+--------------------------------------------+
| 场景                       | 结果                                       |
+----------------------------+--------------------------------------------+
| GBK 字节 + 声明 gbk        | 一切正常                                   |
+----------------------------+--------------------------------------------+
| UTF-8 字节 + 声明 gbk      | 不报错,中文静默变乱码(最难查)           |
+----------------------------+--------------------------------------------+
| GBK 字节 + 声明 utf-8      | 直接 SyntaxError(反而好查)               |
+----------------------------+--------------------------------------------+

2. 跨 bar、跨 tick 的状态,绝对不能存在 ContextInfo 上。

每个 bar 开始前 ContextInfo 会被深拷贝并回滚,存上去的变量下一个 bar 就没了。

PYTHON
class G: pass
g = G()

def init(C):
    g.waiting = {}

def handlebar(C):
    g.waiting['x'] = 1     # 这样才保留得住

3. quickTrade 必须和调用位置匹配,否则静默漏单。

CODE
+------------------------------------------------+--------------+
| 调用位置                                       | quickTrade   |
+------------------------------------------------+--------------+
| handlebar 内,想等 K 线走完再下单              | 0            |
+------------------------------------------------+--------------+
| handlebar 内,想盘中立刻下单                   | 1            |
+------------------------------------------------+--------------+
| init / after_init / 定时器回调 / 订阅回调 / 回 | 必须 2       |
| 报回调                                         |              |
+------------------------------------------------+--------------+

日线及以上周期在 handlebar 里传 0,等于全天不报单。这是「策略跑了但一笔没成交」的头号原因。

4. QMT 是单线程,所有策略共用一个线程。
禁止 while True、禁止长 sleep。任何一个策略卡住线程,其它策略一起停摆。

5. 交易接口是异步的,下单后立刻查询查不到。
get_trade_detail_data 读的是客户端本地缓存,不是现查柜台。有交易主推的柜台 50ms 刷一次,没有的 1~6 秒一次。推论:卖出后立刻查可用资金不会变多;下单后立刻查委托可能查不到。 必须自己维护委托台账。

6. init 和 handlebar 都必须存在。
哪怕你的逻辑全在定时器里,也要写一个空的 handlebar。

7. 行情、订阅、交易日接口只能写成 C.xxx()。
实测这些名字在全局命名空间里根本不存在:get_market_data、get_market_data_ex、get_local_data、get_full_tick、subscribe_quote、subscribe_whole_quote、unsubscribe_quote、get_trading_dates。它们只挂在 ContextInfo 上。唯一双形态的是 get_stock_list_in_sector。

所以官方示例都写 ContextInfo.get_market_data_ex(...) —— 这不是编码风格,是必须。

8. 交易类内置函数只接受位置参数。
get_trade_detail_data(accountID=acc, ...) 会直接抛 did not match C++ signature。这些是 pybind11 暴露的 C++ 函数,参数名不可用。

五、下单:先用对函数,再对参数

推荐只用 passorder。它是唯一覆盖股票 / ETF / 可转债 / 逆回购 / 打新 / 两融的下单函数,也是唯一支持投资备注的常规函数。

PYTHON
passorder(opType, orderType, accountid, orderCode, prType, price, volume,
          strategyName, quickTrade, userOrderId, ContextInfo)

它其实有 5 个重载(8 / 9 / 9 / 10 / 11 参)。这不是从文档抄的 —— 是让客户端自己吐出来的:故意用关键字参数去调用,C++ 报错时会把全部候选签名列出来,比任何文档都准。

CODE
passorder(int, int, str, str, int, double, double, object)                  # 8 参
passorder(int, int, str, str, int, double, double, int,      object)        # 9 参,第 8 位 int
passorder(int, int, str, str, int, double, double, str,      object)        # 9 参,第 8 位 str
passorder(int, int, str, str, int, double, double, str, int, object)        # 10 参
passorder(int, int, str, str, int, double, double, str, int, str, object)   # 11 参

关键:第 8 位是「多态」的 —— 传 int 就当 quickTrade,传 str 就当 strategyName。所以:

PYTHON
passorder(23, 1101, acc, '600901.SH', 14, -1, 100, C)                      # 8 参,quickTrade 等同 0
passorder(23, 1101, acc, '600901.SH', 14, -1, 100, 2, C)                   # 9 参:int = quickTrade
passorder(23, 1101, acc, '600901.SH', 14, -1, 100, '策略名', C)            # 9 参:str = strategyName
passorder(23, 1101, acc, '600901.SH', 14, -1, 100, '策略名', 2, C)         # 10 参:策略名 + quickTrade
passorder(23, 1101, acc, '600901.SH', 14, -1, 100, '策略名', 2, '备注', C)  # 11 参:+ 投资备注

只有投资备注 userOrderId 必须用 11 参,而且必须在有 strategyName 的前提下(不需要就传 '')—— 重载表里没有「跳过策略名直接给备注」的组合。

顺便一个反直觉的发现:orderError_callback 不是「下单失败一定通知你」的保障。 我总共试了 11 种非法调用 —— prType=11 + price=0(文档明确记为必然废单)的 8 / 9 / 10 / 11 参,再加上坏账号、坏代码、坏 orderType、坏 opType、零数量、空备注、空策略名 —— 全部「返回正常、不抛异常」,那个回调一次都没触发。废单信息是打在客户端的「策略信号」栏里的。

要诚实补一句范围:这些都是在「模拟 / 研究」运行模式下测的,而官方文档说交易类回调仅实盘模式生效。所以准确的说法是「模拟/研究模式下它不会被调用」,实盘模式我没测。但无论哪种模式,都不该指望它兜底。所以下单是否成功,只能靠自己维护台账 + 轮询委托来确认。

我最初是靠统计本地代码得出「9 参第 8 位只能是 quickTrade、要写策略名必须凑满 11 参」的结论,那是错的 —— 本地代码碰巧在第 8 位只传过整数。让客户端自己说话,比统计语料可靠。

常用的价格类型:

CODE
+------------+--------------------------+--------------------------------+
| 值         | 含义                     | 备注                           |
+------------+--------------------------+--------------------------------+
| 5          | 最新价                   |                                |
+------------+--------------------------+--------------------------------+
| 11         | 指定价                   | 只有 11 / 49 会真正使用 price  |
+------------+--------------------------+--------------------------------+
| 12         | 涨跌停价                 | 追单托底常用                   |
+------------+--------------------------+--------------------------------+
| 13 / 14    | 本方一档 / 对手价        | 14 是追单首选                  |
+------------+--------------------------+--------------------------------+
| 42~45      | 五档即成剩撤 / 对手方最  | 上交所股票适用,不支持模拟交易 |
|            | 优                       |                                |
+------------+--------------------------+--------------------------------+

实盘里跑得最顺的组合是:对手价 14 首发 → 未成交撤单 → 涨跌停价 12 追单,形成二级追单。

六、撤单与查询

PYTHON
cancel(orderId, accountId, accountType, ContextInfo)      # 返回 bool
orderId = get_last_order_id(accountId, 'STOCK', 'ORDER')  # 返回字符串,找不到是 '-1'
obj     = get_value_by_order_id(orderId, accountId, 'STOCK', 'ORDER')
can_cancel_order(orderId, accountId, 'STOCK')             # 判能否撤单

⚠ get_last_order_id 有延迟,而且委托废单时它返回的是「上一次成功下单的委托号」。 所以它不能当「本次下单是否成功」的判据 —— 这是很多自动追单逻辑出错的地方。实测它返回的是字符串 '-1',所以判空要写 == '-1',写成 == -1 永远不成立。

查资金、持仓、委托、成交:

PYTHON
get_trade_detail_data(accountID, accountType, datatype[, strategyName])
# datatype    : ACCOUNT / POSITION / POSITION_STATISTICS / ORDER / DEAL / TASK
# accountType : STOCK / CREDIT / FUTURE / STOCK_OPTION,大小写不敏感

⚠ 第 4 个参数是按 strategyName(passorder 第 8 参)过滤,不是按 m_strRemark(投资备注)。而绝大多数台账去重都用 m_strRemark 做 key —— 这两个不是同一个东西,不要指望用第 4 参做逐笔去重。

另外要记住:get_trade_detail_data 读的是本地缓存。想让状态「立刻更新」是做不到的,只能轮询 + 超时。

七、常见陷阱清单

CODE
+-----+-----------------------------+------------------------------------+
|   # | 现象                        | 根因 / 对策                        |
+-----+-----------------------------+------------------------------------+
|   1 | 跑了一整天一笔委托都没有    | quickTrade 与调用位置不匹配;回调  |
|     |                             | 内要传 2                           |
+-----+-----------------------------+------------------------------------+
|   2 | 对手价下单报「对手价无效」  | 全推行情只有一档,客户端要改成五档 |
+-----+-----------------------------+------------------------------------+
|   3 | 变量莫名被重置              | 存到 ContextInfo 上了,改用模块级  |
|     |                             | 对象                               |
+-----+-----------------------------+------------------------------------+
|   4 | 中文日志乱码                | 声明编码 ≠ 文件实际字节编码        |
+-----+-----------------------------+------------------------------------+
|   5 | 委托成功但卖不出去          | 用了 m_nVolume,应该用 m_nCanUseVo |
|     |                             | lume                               |
+-----+-----------------------------+------------------------------------+
|   6 | 数量被废单                  | 没按品种最小单位取整               |
+-----+-----------------------------+------------------------------------+
|   7 | 委托重复 / 超单             | 没有「待报」台账                   |
+-----+-----------------------------+------------------------------------+
|   8 | 判方向判反                  | 用了 m_nDirection,股票恒为 48     |
+-----+-----------------------------+------------------------------------+
|   9 | 数据全是重复值              | 超过订阅上限,客户端用前值填充     |
+-----+-----------------------------+------------------------------------+
|  10 | 定时器在回测里不触发        | run_time 回测无效,回测要走 handle |
|     |                             | bar                                |
+-----+-----------------------------+------------------------------------+

八、交付前自检清单

写完一版策略,过一遍这十二条再上实盘:

  • 首行有编码声明,且与文件实际字节编码一致
  • init / handlebar 都存在
  • 所有跨 bar、跨 tick 的状态都在模块级对象里,没塞进 ContextInfo
  • 每个下单调用的 quickTrade 都与调用位置匹配(回调内必须是 2)
  • init 里调了 C.set_account(账号)
  • 价格参数:非「指定价 / 盘后定价」时填了占位值(-1 或 0)
  • 数量按品种取了正确的最小单位
  • 卖出数量 ≤ 可卖数量
  • 所有内置函数都是位置传参
  • 没有 while True、没有秒级以上 sleep
  • 有委托台账,且「未查到委托」时暂停该品种的后续报单
  • stop 里没有报单、撤单

小结

QMT 的坑其实高度集中在这五处:编码、ContextInfo 回滚、quickTrade、字段名、单位换算。把它们前置到「写之前就知道」,比事后对着毫无指向性的报错瞎猜要省力得多。

下一篇讲另外半边:怎么判品种(以及一个会静默返回空列表的板块名陷阱)、三套行情数据源怎么分开用、交易对象字段里那个会让止盈止损彻底失效的「哨兵值」,以及我认为最有用的一招 —— 怎么让客户端自己回答「这个语法到底行不行」。

💬 评论 (0)

今日还可评论 3 条

还没有评论,来抢沙发吧。