AI Skill 给大 QMT 策略做代码评审(二):QMT 内置日内回转策略

前阵子给自己的 AI 助手写了个 Skill,取名 qmt-strategy-review。它就干一件事:丢一段 QMT 策略代码进去,对照官方文档逐条查 API 签名、状态管理、单线程陷阱和实盘可用性,吐一份能直接用来决策改不改、改什么的审阅报告——不是那种”你这里有个错误”的空泛评论,是带着改法和风险等级的。

这个 Skill 很快上架,但目前只在我本地的 Skill 目录里跑。等市场能搜到了我再更这篇。

为什么要写这个 Skill

其实就是一句话:直接跟 AI 聊天审 QMT 代码,每次都要重复那些前置背景,烦。

QMT 这套东西有几个特点:API 签名散在十几页文档里、ContextInfo 逐 K 线回滚是单线程里最容易踩的隐蔽坑、order_shares 这种函数只在回测里有效但文档藏在角落、编码声明和实际编码不一致会导致盘中静默挂掉。每次我把一段策略代码贴给普通 AI,它要么漏查这些特定约束,要么让我把文档片段再贴一遍。

把这些查档路径和核查规则固化成 Skill 之后,下次丢代码进去就不用再说”记得查 ContextInfo 回滚机制,翻《常见问题》那页”——Skill 自己知道要查哪几页文档、按什么顺序对、哪些严重错误必须标红。一次调到位,后续对话都能复用。

这也是为什么我只让它审工程层:策略逻辑好不好、参数选得对不对,那是交易系统的问题;而”这个函数实盘会静默失效”、”这个状态变量会被 K 线回滚吞掉”——这些是任何 QMT 策略不管做什么都绕不开的技术约束,适合做成一次性跑通的 Skill。


真实案例:官方日内回转示例策略

我把官方那份日内回转实盘示例丢进去,跑出来的报告是这样:


第一部分:评审范围说明

本次只检查代码编写与代码实现层:单线程阻塞、数据层效率、ContextInfo 误用与状态管理(逐 K 线回滚、深拷贝成本)、API 签名与枚举值(均对照 QMT 官方文档当次核实)、编码声明与乱码风险、Python 代码层错误、回放与重复下单防护、日志写法。

  • 不检查策略逻辑:MACD 参数 12/26/9 是否合理、信号是否有超额、回转规则赚不赚钱,属于策略逻辑范畴,本节不评价、不预测。
  • 不收集、不存储策略代码:评审只在当前对话内完成,不落盘、不复制、不写入任何记忆或日志文件,阶段一连改后文件的副本都不生成。

先说结论里最要紧的一句:这个文件头部自己写了“回测模型示例(非实盘交易策略)”,这句话比任何评审意见都关键——它用的 order_shares 官方明确标注“仅回测可用”。你的目标是回测还是实盘,决定了下面哪些条目必须改。


第二部分:修改意见

汇总表

编号 级别 位置 问题一句话
严1 严重 L46/L80/L88/L98/L107 order_shares 官方标注“仅回测可用”,模拟盘与实盘调用不产生任何委托
严2 严重 L17/L52/L84/L93/L97/L106 持仓记账 MarketPosition 挂在 ContextInfo 上,盘中逐 K 线回退会算错仓位
警1 警告 L39 get_market_data 已标注【不推荐】且返回类型随参数变化,代码强依赖 DataFrame 形态
警2 警告 L59/L76 收盘回转窗口只有 14:55 这一分钟,14:56 起直接 return,漏一分钟就隔夜裸仓
警3 警告 L20/L79/L80 Lots 单位前后不一致:资金检查按 100 手算,实际只买 100 股
提1 提示 L12/L35 talib 依赖需本地确认;每根 bar 都 print 日期,回测日志爆量
提2 提示 L36/L37 flage / singleemited 是死变量,画图代码被注释后无人使用
提3 提示 L51/L56/L116 day 状态只写不读,原意被注释掉了
提4 提示 L24 账号配置挂在 ContextInfo 上(官方未列为只读,但属于该迁走的配置)
提5 提示 L39 get_universe 在官方文档页面未检索到定义,只能以本地实测为准

总体结论:回测用途成立,不能直接上实盘。 严1 决定了这份代码只能在“回测”按钮下工作——切到模拟信号或实盘模式,5 处下单全部静默失效,策略看起来在跑、日志也在打,但一笔委托都不会发出去;严2 则是一旦真的在盘中跑起来,同一根 K 线内的多次记账会被回退,收盘回转会按错误仓位补单。最大风险就是这两条叠在一起。

逐条详述

严1(严重)order_shares 只在回测里有效

  • 现象:L46、L80、L88、L98、L107 五处下单都用 order_shares。
  • 原因:官方文档把 order_shares 放在「其他交易函数(仅回测可用)」章节,章节开头原文是“以下函数仅回测生效,实盘和模拟盘交易均不可用”。也就是说:回测模式下它按 ContextInfo.capital 虚拟撮合;一旦你用「模拟信号」或「实盘」模式启动,这五处调用不会产生任何委托,也不会有报错提示——最危险的一种失效方式。
  • 依据:QMT 官方文档《交易函数》- 其他交易函数(仅回测可用)。
  • 签名核对:order_shares(stockcode, shares[, style, price], ContextInfo[, accId]),shares 正数买、负数卖,'fix' 为指定价(官方示例即写小写 'fix')——你现有的参数顺序是对的,问题不在写法,在于函数的用途边界。
  • 改法(未落盘):要上模拟/实盘,换成 passorder(23/24, 1101, account, code, prType, price, volume, '策略名', quickTrade, 备注, C),quickTrade=0 配合 K 线走完再下单。若只是想少改,可保留 order_shares 专供回测,另写一个 _order() 包装函数按运行模式分支。
  • 回归:模拟信号模式下确认「策略信号」栏有记录;回测结果数字与改前一致。

严2(严重)持仓记账挂在 ContextInfo 上

  • 现象:ContextInfo.MarketPosition(L17 建立,L52/L84/L93/L97/L106/L113 读写)、ContextInfo.first、ContextInfo.day 全部是挂在 ContextInfo 上的自定义属性。
  • 原因:官方《使用须知》原文——“由于底层机制的限制,ContextInfo 中存储的变量值将会回滚……在完全理解 ContextInfo 机制之前,请避免在其中存储任何变量”,并给了推荐写法 class G(): pass + 全局实例。机制细节(《常见问题》- 系统对象 ContextInfo 逐 k 线保存的机制):每次 handlebar 前深拷贝,若新分笔不是该 K 线第一个分笔,就回退到深拷贝——只有 K 线结束时最后一个分笔的修改才有效。回测里每根 bar 只调用一次,所以侥幸不出事;盘中每个 tick 都调用,MarketPosition += Lots 这类累加会被吞掉,14:55 回转时算出来的仓位是错的,可能多买或少卖。
  • 依据:QMT 官方文档《使用须知》、《常见问题》- ContextInfo 逐 k 线保存机制。
  • 改法(未落盘):把这些状态整体搬到模块级单例 class G(): pass; g = G(),g.pos、g.first、g.day 直接用,不再经过 ContextInfo。
  • 回归:回测结果应与改前完全一致(回测下每 bar 一次调用,搬迁不改语义);盘中打印 g.pos 确认同一根 K 线内多次触发时数值不再回退。

警1(警告)get_market_data 返回形态不稳定

  • 现象:df = get_market_data(['close'], stock_code=..., start_time, end_time, period=...),随后依赖 df.iloc[-1, 0]、df.iloc[-35:, 0]、df.empty——即假定“行=时间、第 0 列=close 的 DataFrame”。
  • 原因:官方页面标题即标【不推荐】,并明确“返回值根据传入的参数情况,会返回不同类型”:单股票+单字段+单时间点可能返回 float,多字段单股票返回 Series,多股票返回 index 为代码的 DataFrame,多字段多时间点返回 Panel(新版 pandas 已移除 Panel)。你这里是单股票单字段多时间点,按文档应落进 DataFrame 分支,但文档没有明确该分支的 index/columns 语义,iloc 的写法属于对未定义形状的依赖;换客户端版本或改参数就可能 AttributeError: 'float' object has no attribute 'iloc'。
  • 依据:QMT 官方文档《行情函数》- get_market_data(不推荐)。
  • 改法(未落盘):迁到 get_market_data_ex(['close'], [code], period=..., count=35),返回稳定 {code: DataFrame(index=时间, columns=字段)},取 df['close'].values 即可,顺带省掉 start_time/end_time 的字符串拼装。
  • 回归:取数长度 35、最后一根收盘价与主图一致。

警2(警告)回转窗口只有一分钟

  • 现象:L59 if int(date[-8:-6] + date[-5:-3]) > 1455: return,14:56 起直接返回;L76 的 if date[-8:-3] != '14:55' 决定只有 14:55:00–14:55:59 这一分钟走回转分支。
  • 原因:设计意图是“收盘前把仓位调回原仓位”,但实际可执行窗口只有一分钟。若这一分钟没有 bar(停牌、断线重连、客户端重启、数据缺口),回转永不执行,仓位带过夜,与你注释里的设计目标相反。另外这段用字符串切片拼 '1455' 再转 int,格式一变就静默失效。
  • 依据:QMT 官方文档《变量约定》- timetag_to_datetime 格式化用法(建议直接解析时间对象,而不是切字符串)。
  • 改法(未落盘):改成“时间窗口”而非“某一分钟”,例如 >= 145000 到 150000 之间都允许回转,且回转完成后置一个 g.done_today 标记避免重复;时间用 datetime.strptime 或直接比较 HHMMSS 整数。
  • 回归:故意让 14:55 那根 bar 缺失(改窗口起点到 14:50 模拟),确认回转仍会执行且只执行一次。

警3(警告)Lots 单位不一致

  • 现象:L20 Lots = 100 # 设定交易手数;L79 资金检查用 price * Lots * 100(按 100 手 = 10000 股算);L80 下单 order_shares(code, Lots, ...) 只买 100 股;L84 记账也 += Lots。
  • 原因:资金门槛按万股价校验,实际只下百股,检查形同虚设;如果以后把 Lots 当“手数”调成 10,程序会买 10 股(不足 1 手,直接废单)。
  • 改法:统一为“股数”一个口径,变量名改 LotShares = 100,资金检查用 price * LotShares;或统一为“手数”,下单时传 Lots * 100。
  • 回归:改完后打印一次下单股数与资金占用,与手算一致。

提1(提示)外部依赖与日志量:import talib 是否可用取决于客户端“下载 Python 库”是否补全(官方《使用须知》- 下载 python 库),官方文档未承诺默认随附,请在本地确认;另外官方《引用函数》页面提供 MA/MACD/KDJ 等指标引用函数,若不想依赖 talib 可改用它。L35 每根 bar 都 print('日期', date),回测几千根 bar 会让日志非常庞大,建议只在信号触发时打印。

提2(提示)死变量:flage(L36)与 singleemited(L37)只在被赋值的分支里使用或根本没人读,画图代码注释掉后就成了死代码,删掉更清爽。

提3(提示)day 状态只写不读:L51、L56 往 ContextInfo.day 写值,L116-117 才是原本“更新过去日期数据”的意图,但已被注释;现在这个状态不参与任何判断,属于遗留物。

提4(提示)accountID 挂 ContextInfo:静态扫描把它判成“写只读属性”,我核对官方《变量约定》未发现 accountID 的只读定义(聚宽迁移示例里官方自己就示范过给 C 挂账号字段),因此这条降级为写法建议:账号属于配置,放模块级更合适,也更符合“ContextInfo 不存变量”的官方指引。

提5(提示)get_universe:官方《行情函数》页面只收录了 set_universe(stock_list) 的调用示例(与你 L18 的写法一致),get_universe() 的原型在三张页面里都没检索到,无法为你核实签名;它在你本地能跑就继续用,但更稳妥的写法是用模块级变量存 code:

1
2
3
4
# [qmt-strategy-review] ---- 候选替换(未应用,确认 提5 后启用)----
# g.code = ContextInfo.stockcode + '.' + ContextInfo.market
# ... 后续统一用 g.code,不再反复调 get_universe()
# [qmt-strategy-review] ---- /候选替换 ----

已核实无误(正面确认):C.stockcode + '.' + C.market 拼接格式正确;C.period(L40 传入 period)是只读属性、取值 '1m' 等与文档一致;timetag_to_datetime(ContextInfo.get_bar_timetag(ContextInfo.barpos), '%Y-%m-%d %H:%M:%S') 与官方《变量约定》示例逐字一致;get_trade_detail_data(accountid, 'STOCK', 'ACCOUNT'/'POSITION') 参数顺序与《交易函数》一致;set_universe 用法与官方示例一致。全篇无 sleep、无线程库、无 count=-1 无界取数、无网络/文件外接。


第三部分:代码节选 + 注释(原样,逻辑零改动)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# coding:gbk
# [qmt-strategy-review] ============ 评审(阶段一,未修改任何逻辑)============
# [qmt-strategy-review] 本文件自述为"回测模型示例", 因此分两种用途读下面的意见:
# 只做回测 -> 严1 / 严2 不影响结果, 重点是 通1 与 警2
# 要上模拟或实盘 -> 严1 / 严2 必须改, 否则不下单或持仓记账错乱
'''
回测模型示例(非实盘交易策略)

本策略首先买入当前股票10000股
随后根据60s的数据来计算MACD(12,26,9)线,并在MACD>0,MACD_pre<0的时候买入100股,MACD<0,MACD_pre>0的时候卖出100股
但每日操作的股票数不超过原有仓位,并于收盘前把仓位调整至开盘前的仓位
本策略需在个股分钟线下运行
'''
import numpy as np
import pandas as pd
# [qmt-strategy-review][提1] talib 是否随客户端提供, 官方文档未承诺, 需本地"下载 Python 库"后确认;
# 官方《引用函数》另有 MA / MACD / KDJ 等指标引用函数可作替代
import talib


def init(ContextInfo):
MarketPosition = {}
# [qmt-strategy-review][严2] 持仓记账挂在 ContextInfo 上, 盘中逐 K 线回退会丢累加
# 原因: 每次 handlebar 前深拷贝, 非该K线最后分笔的修改会被回退, 只有K线结束分笔的修改生效
# 依据: QMT 官方文档《使用须知》(避免在 ContextInfo 存变量, 推荐 class G) 与
# 《常见问题》- 系统对象 ContextInfo 逐 k 线保存的机制
# 改法: 迁到模块级单例 g.pos, 见下方候选替换
ContextInfo.MarketPosition = MarketPosition # 初始化持仓
ContextInfo.set_universe([ContextInfo.stockcode + '.' + ContextInfo.market])
# [qmt-strategy-review] 已核实: set_universe 用法与官方《行情函数》示例一致
# [qmt-strategy-review][严2] first 属于"只执行一次"的开关, 同样不能存 ContextInfo
ContextInfo.first = 0
# [qmt-strategy-review][警3] Lots 注释写"手数", 但 L79 资金检查按 Lots*100 股算,
# L80 下单只传 Lots 股 -> 两个口径不一致, 资金检查形同虚设; 建议统一为股数
ContextInfo.Lots = 100 # 设定交易手数
# [qmt-strategy-review][提3] day 只写不读 (L51 / L56), 原意所在的 L116 已被注释, 属遗留状态
ContextInfo.day = [0, 0]
ContextInfo.ending = 0
ContextInfo.total = 10000
# [qmt-strategy-review][提4] 账号属配置项, 官方未把 accountID 列为只读, 但按《使用须知》
# 不宜挂 ContextInfo; 建议放模块级, 或直接用界面注入的 account
ContextInfo.accountID = 'testS'


# [qmt-strategy-review] ---- 候选替换(未应用,确认 严2 后启用)----
# class G(): pass
# g = G()
# def init(ContextInfo):
# g.pos = {}
# g.first = 0
# g.day = [0, 0]
# g.done_today = False
# [qmt-strategy-review] ---- /候选替换 ----

def handlebar(ContextInfo):
d = ContextInfo.barpos
# [qmt-strategy-review] 已核实: barpos 为只读属性, 索引从 0 开始 (官方文档《变量约定》)
if d < 35:
return
# [qmt-strategy-review] 已核实: timetag_to_datetime(get_bar_timetag(barpos), fmt) 的写法
# 与官方《变量约定》示例逐字一致, 参数顺序正确
startdate = timetag_to_datetime(ContextInfo.get_bar_timetag(d - 35), '%Y%m%d%H%M%S')
enddate = timetag_to_datetime(ContextInfo.get_bar_timetag(d), '%Y%m%d%H%M%S')
##print startdate,enddate
date = timetag_to_datetime(ContextInfo.get_bar_timetag(d), '%Y-%m-%d %H:%M:%S')
# [qmt-strategy-review][提1] 每根 bar 都 print, 回测日志量巨大, 建议只在信号触发时打印
print('日期', date)
# [qmt-strategy-review][提2] flage / singleemited 在画图代码被注释后已成死变量, 可删
flage = False
singleemited = False

> 策略代码全文和审阅完整版报告请在 WTSolutions 公众号后台发送“代码评审”获取。

要不要我落地改? 请回编号,例如「改 通1、严1、警2」或「全改」。动之前我会先复制 日内回转.py.bak,只改你点到的条目,改完重跑静态审查并对比严重项数量。另外有两种典型组合,可以直接说:

  • 只做回测 → 只需「通1 + 警2 + 提1(日志限流)」,其余保留现状即可。
  • 要上模拟/实盘 → 必改「通1 + 严1 + 严2 + 警2 + 警3」,这是一次结构性改造(passorder + 委托状态机 + 模块级状态),我会给完整骨架。

⚠️ 实盘风险提示:涉及真实下单。请先在 QMT「模拟信号模式」或「模拟柜台」中充分验证后,再切换实盘账号;股票受 2% 价格笼子限制,数量超可用会产生废单。


现状与后续

qmt-strategy-review 目前只装在我自己的本地 Skill 目录里,配合 QMT 官方 API 文档在跑。等它上架到 Skill 市场能搜到了我再回来补个链接。

策略代码全文和审阅完整版报告请在 WTSolutions 公众号后台发送“代码评审”获取。


风险提示

  • 本文是技术分享,不构成任何投资建议、荐股意见或收益承诺。
  • 文中评审意见已标注官方文档依据,但 AI 也会看错页面或漏掉更新;请以 QMT 官方文档与你本地客户端的实际行为为准。
  • passorder 等交易接口实盘模式下真实发出委托,任何策略在上实盘前必须先在 QMT「模拟信号模式」或「模拟柜台」跑通。
  • 因使用本文内容造成的盈亏由使用者自行承担。量化交易涉及真实资金风险,入市需谨慎。
  • QMT、迅投为迅投公司或其关联公司的商标,本文为社区非官方整理,与官方无隶属关系。