本系列前两篇:
(一) 《QMT 单线程指南(一):多策略怎么排队、该不该把策略写在一起》
(二) 《QMT 单线程指南(二):C 语言、Cython 与胶水语言,十一个问答》
文中(一)(二)指上述本系列文章。
文中《系统函数》《变量约定》《常见问题》《使用须知》《快速开始》这类短书名号,一律指 QMT 官方知识库中的对应篇目;
§后接的是该篇目下的小节标题。
一、先把三个坑摆出来
这三个坑,都是「把 ContextInfo「后面简称 C」 当成普通 Python 对象」的直接后果:
- 计数永远加不上去——
handlebar里写C.count += 1,盘中打印出来反复是 1,偶尔跳一下。不是没执行,是执行完被回退了。 - 策略莫名其妙变慢,还带着别的策略一起慢——把 300 根 K 线的 DataFrame、股票池、信号表全挂到
C上,每个分笔都被完整复制一遍。(二)里那句「单次回调耗时是公共的」,在这件事上体现得最直接。 - 立刻下单的模型状态对不上——用
C.waiting_list记待报委托,报单后查不到、或重连后重复报单。官方文档对这种情况有一句很硬的话:下单记录可以用普通的全局变量保存,不能存在ContextInfo对象的属性里。
三个场景指向同一句话:C 不是你自己 new 出来的对象,它是底层借给你、按 K 线回收的草稿纸。
二、ContextInfo 是什么:官方文档里那几句”打架”的话
先把散落在知识库各处的描述摆在一起:
| 出处 | 原文要点 | 讲的是 |
|---|---|---|
| 《系统函数》§ init 参数表 | ContextInfo:策略运行环境对象,可以用于存储自定义的全局变量 |
能力:能挂属性 |
| 《系统函数》§ ContextInfo 对象 | 一般情况下不建议添加自定义属性,它会随 bar 切换重置到上一根 bar 的结束状态,建议用自建的全局变量来存储 | 建议:能挂,但别挂 |
| 《变量约定》 | ContextInfo 上下文对象;period / stockcode / market / barpos / do_back_test 等为只读属性 |
身份:底层维护、传给钩子的运行时对象 |
| 《常见问题》§ 系统对象 ContextInfo 逐 k 线保存的机制 | 由底层维护并传递给 init、handlebar 等系统函数;每次 handlebar 调用前深拷贝;非 K 线结束分笔的修改被回退 |
时机:修改何时生效 |
| 《快速开始》§ 逐 K 线驱动 | 盘中分笔驱动,但是逐 K 线生效 | 一句话总纲 |
ContextInfo能挂自定义属性,但它不是普通 Python 对象——写入的「提交」按 K 线走,不按赋值语句走;所以官方建议用自建的全局变量来存状态。
你不是持有者,是借用者。每次调用拿到的,是一份可能被回收的视图。
它的生命周期大致长这样:
1 | [策略启动] |
注意中间那行「对本次调用的下文起作用」——这是官方原话。它意味着:C.x = 1 后面紧跟 print(C.x) 一定能看到 1,但下一次 handlebar 未必还能看到。 这个「本次有效、下次不一定」,就是绝大多数状态 bug 的来源。
三、机制逐帧:深拷贝 → 回滚 → 事后追认
官方原文(《常见问题》)里有两句最关键,逐字抄在这儿:
handlebar里对ContextInfo做的修改在该 bar 结束后才会进行保存,也就是说,对ContextInfo做的修改会在下一个 bar 体现出来。
每次
handlebar函数调用前会对ContextInfo对象进行深拷贝,下一次分笔行情到来时,如果新的分笔不是新 k 线 bar 第一个分笔,则判断上一个分笔不是 K 线最后分笔,ContextInfo对象被回退为之前深拷贝的那个。
把第二句画成时间线(以一根 1 分钟 K 线里来了 4 个分笔为例,仅示意):
| 分笔 | 调用前 | 本次写入 | 下一个分笔是不是「新 K 线首个分笔」 | 本次写入的结果 |
|---|---|---|---|---|
| t1(本根第 1 个) | 深拷贝得快照 S0 | C.x = 1 |
否(t2 还在这根里) | 回退到 S0,x 丢失 |
| t2 | 深拷贝 | C.x = 2 |
否 | 回退 |
| t3(本根最后一个) | 深拷贝 | C.x = 3 |
是(t4 属于下一根 K 线) | 保留,下一根能看到 x=3 |
| t4(新根第 1 个) | 深拷贝得 S1(已含 x=3) |
… | 进入下一轮 |
这里有个(一)没讲透的点:最后一个分笔是「被追认」的
没有任何一个分笔自己知道「我是这根 K 线的最后一个」。 它是由下一个分笔反推出来的:下一个分笔若是新 K 线的第一个分笔,才说明上一个分笔是上一根的收尾,上一份修改才被留下来。
所以这不是「写入时判断」,是「事后追认」。三个直接推论:
- 盘中绝大多数
handlebar调用,对C的写入都是白写——只有每根 K 线收尾那一次被留下; - 改的是”下一个 bar 的世界”——官方说「在下一个 bar 体现出来」,本次调用之后的同根分笔看不到;
is_new_bar()正好是那个旁观者。(一)Q6 讲过它「某根 K 线的第一个 tick 到来时返回 True」,用来做收根结算;放到这里看,它标定的时刻恰好就是「上一根被追认」的时刻。两个函数是同一套节奏的两面,别混用:is_last_bar()回答「是不是最右侧那根」,is_new_bar()回答「是不是这根的头一个 tick」。
四、代价:深拷贝是「按对象大小收费」的
(一)第三节只留了一句「每个分笔都深拷贝会拖慢策略运行」,没说清它为什么在(二)的性能语境里格外要命。补三条:
1. 不是「改了才拷」,是每次调用前都拷。 哪怕你 handlebar 第一行就 if not C.is_last_bar(): return,底层也已经先拷了一份。这份开销和你这次干不干活无关。
2. 拷贝成本 ∝ C 上挂了多少东西。 挂一个 int 几乎免费;挂一份 300 根 × N 品种的行情面板、一个股票池 list、一个订单簿 dict,就是每个分笔递归复制一整棵对象树。(二)里反复强调「单次回调耗时是公共的」——这份拷贝跑在唯一那条线程上,同一客户端所有策略一起买单。
3. (一)Q5 那条收益由此而来。 合并策略省下的三样里,「ContextInfo 深拷贝从 N 份变 1 份」是最容易被忽略的一项:N 个策略 = 每 tick N 份深拷贝;合成一个调度型策略 = 1 份。这也是「按故障域拆、按共享计算合」之外,合并唯一实打实的技术理由。
想粗估一下量级,用标准 Python 就能测(只是量级估算,不是 QMT 底层实现的等价测量):
1 | # coding:gbk |
把 obj 换成你自己的 DataFrame 或股票池,就能知道「每 tick 多交多少税」。再乘上股票 tick 约 3 秒一次、一天几千次触发,以及同客户端挂着的其他策略——(二)那句「省下的每一毫秒是公共的」,反过来读就是这里。
五、十个问题
Q1:ContextInfo 到底算不算”全局变量”?
答:能挂属性,但不是普通全局变量——它的写入按 K 线提交。
《系统函数》说它「可以用于存储自定义的全局变量」,讲的是能力;《常见问题》说它「逐 K 线保存、会被回退」,讲的是时机;而《系统函数》§ ContextInfo 对象 更是直接建议「用自建的全局变量来存储」。把两句拼起来才完整:能存 ≠ 立刻存得住 ≠ 每次都存得住。
一句话记忆:模块级全局是当场生效的账本,C 是按 K 线提交的草稿纸。
Q2:我在 handlebar 里 C.count += 1,为什么每次打印都是 1?
答:被回滚了。本次调用内确实是 1,下一次调用拿到的是深拷贝快照。
自己验证最清楚,同一段逻辑放两个位置对照:
1 | # coding:gbk |
盘中会看到 g.n 稳定递增,而 C.n 大多停在 1、只在跨 K 线时偶尔跳一下——那个「偶尔跳一下」,正好是 Q3 说的事后追认。挂模拟信号模式跑十分钟,比看十遍文档直观。
往下还有八个问题、五个小节,先把目录摆在这儿:
- Q3:「K 线结束的那个分笔」是谁认定的?
- Q4:深拷贝到底拷什么?挂大对象会怎样?
- Q5:哪些东西放
C是官方认可的用法? - Q6:哪些状态必须放模块级全局?
- Q7:三档
quickTrade和状态存放怎么对应? - Q8:
subscribe/run_time/after_init回调里的C,和handlebar里的是同一个吗? - Q9:
ContextInfo能不能跨策略共享使用? - Q10:断线重连 / 重新运行之后,状态还剩什么?
末尾还有五节:状态该存哪儿:一张决策表(含两个骨架片段与五个容易做错的设计点)、常见问题排查表、落地三步走、免责声明(请务必完整阅读)、以及写在最后。
继续后续阅读:
风险提示:本文只讨论 QMT 内置 Python 的编程机制与工程写法,不含任何交易策略效果或买卖判断,也不构成任何投资建议;文中均为思路与局部片段,实盘前必须在模拟信号模式或模拟柜台充分测试,因客户端版本差异导致的运行后果由使用者自行承担。