QMT 单线程指南(三):ContextInfo 是什么、状态该存哪儿,十个问答

本系列前两篇:
(一) 《QMT 单线程指南(一):多策略怎么排队、该不该把策略写在一起》
(二) 《QMT 单线程指南(二):C 语言、Cython 与胶水语言,十一个问答》
文中(一)(二)指上述本系列文章。

文中《系统函数》《变量约定》《常见问题》《使用须知》《快速开始》这类短书名号,一律指 QMT 官方知识库中的对应篇目;§ 后接的是该篇目下的小节标题。

一、先把三个坑摆出来

这三个坑,都是「把 ContextInfo「后面简称 C」 当成普通 Python 对象」的直接后果:

  1. 计数永远加不上去——handlebar 里写 C.count += 1,盘中打印出来反复是 1,偶尔跳一下。不是没执行,是执行完被回退了。
  2. 策略莫名其妙变慢,还带着别的策略一起慢——把 300 根 K 线的 DataFrame、股票池、信号表全挂到 C 上,每个分笔都被完整复制一遍。(二)里那句「单次回调耗时是公共的」,在这件事上体现得最直接。
  3. 立刻下单的模型状态对不上——用 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
2
3
4
5
6
7
8
9
10
11
12
[策略启动]
│
init(C) 只执行一次,可在 init 内设参数(如 start/end 明确要求仅在 init 生效)
│
after_init(C) 官方:init 执行完后、handlebar 之前调用一次
│
├─ 历史回放:所选周期历史 K 线,每根触发一次 handlebar(C)
└─ 盘中:主图品种每个 Level 1 分笔,触发一次 handlebar(C)
│
├─ 调用【前】:底层对 C 做一次深拷贝(快照)
├─ 调用【中】:本次改的 C.xxx,对本次调用的【下文】可见
└─ 下一次分笔到来:决定这份修改是【保留】还是【回退】

注意中间那行「对本次调用的下文起作用」——这是官方原话。它意味着: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 线的第一个分笔,才说明上一个分笔是上一根的收尾,上一份修改才被留下来。

所以这不是「写入时判断」,是「事后追认」。三个直接推论:

  1. 盘中绝大多数 handlebar 调用,对 C 的写入都是白写——只有每根 K 线收尾那一次被留下;
  2. 改的是”下一个 bar 的世界”——官方说「在下一个 bar 体现出来」,本次调用之后的同根分笔看不到;
  3. 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
2
3
4
5
6
7
8
# coding:gbk
# 诊断片段:估算「一份缓存被反复深拷贝」的单次成本
import copy, time
obj = {str(i): [0.0] * 200 for i in range(200)} # 模拟一份挂在 C 上的缓存
t0 = time.perf_counter()
for _ in range(100):
_ = copy.deepcopy(obj)
print('单次约 %.2f ms' % ((time.perf_counter() - t0) / 100 * 1000))

把 obj 换成你自己的 DataFrame 或股票池,就能知道「每 tick 多交多少税」。再乘上股票 tick 约 3 秒一次、一天几千次触发,以及同客户端挂着的其他策略——(二)那句「省下的每一毫秒是公共的」,反过来读就是这里。

五、十个问题

Q1:ContextInfo 到底算不算”全局变量”?

答:能挂属性,但不是普通全局变量——它的写入按 K 线提交。

《系统函数》说它「可以用于存储自定义的全局变量」,讲的是能力;《常见问题》说它「逐 K 线保存、会被回退」,讲的是时机;而《系统函数》§ ContextInfo 对象 更是直接建议「用自建的全局变量来存储」。把两句拼起来才完整:能存 ≠ 立刻存得住 ≠ 每次都存得住。

一句话记忆:模块级全局是当场生效的账本,C 是按 K 线提交的草稿纸。

Q2:我在 handlebar 里 C.count += 1,为什么每次打印都是 1?

答:被回滚了。本次调用内确实是 1,下一次调用拿到的是深拷贝快照。

自己验证最清楚,同一段逻辑放两个位置对照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# coding:gbk
# 诊断片段:同一段累加,两种存放位置的差别
class G():
pass
g = G()
g.n = 0

def init(C):
C.n = 0

def handlebar(C):
if not C.is_last_bar():
return
C.n += 1
g.n += 1
print('C.n =', C.n, ' g.n =', g.n)

盘中会看到 g.n 稳定递增,而 C.n 大多停在 1、只在跨 K 线时偶尔跳一下——那个「偶尔跳一下」,正好是 Q3 说的事后追认。挂模拟信号模式跑十分钟,比看十遍文档直观。


往下还有八个问题、五个小节,先把目录摆在这儿:

  1. Q3:「K 线结束的那个分笔」是谁认定的?
  2. Q4:深拷贝到底拷什么?挂大对象会怎样?
  3. Q5:哪些东西放 C 是官方认可的用法?
  4. Q6:哪些状态必须放模块级全局?
  5. Q7:三档 quickTrade 和状态存放怎么对应?
  6. Q8:subscribe / run_time / after_init 回调里的 C,和 handlebar 里的是同一个吗?
  7. Q9:ContextInfo 能不能跨策略共享使用?
  8. Q10:断线重连 / 重新运行之后,状态还剩什么?

末尾还有五节:状态该存哪儿:一张决策表(含两个骨架片段与五个容易做错的设计点)、常见问题排查表、落地三步走、免责声明(请务必完整阅读)、以及写在最后。

继续后续阅读:

风险提示:本文只讨论 QMT 内置 Python 的编程机制与工程写法,不含任何交易策略效果或买卖判断,也不构成任何投资建议;文中均为思路与局部片段,实盘前必须在模拟信号模式或模拟柜台充分测试,因客户端版本差异导致的运行后果由使用者自行承担。