☰
QMT量化实战:通达信高级状态函数Python复现与自动交易指南
2026/9/29 15:25:38 网站建设 项目流程

去年写完《QMT 量化实战:通达信指标完美复现指南(上)》之后,我发现后台和私信里问得最多的,反而不是“MACD 怎么平移”这种基础函数搬运,而是同一个痛点的不同说法:“通达信里明明画得出来,同样的逻辑搬到 QMT 里就是不对”“K线图形能对上,一到买卖信号就对不上”。这些提问者的共同特征很明显:他们卡住的都不是纯计算类指标,而是带状态、带过滤、甚至跨周期引用的那类“进阶指标”。所以这篇下篇,我打算把这几类彻底讲透:先用一张难度分级表帮你对号入座,然后专门讲 BARSLAST、VALUEWHEN、FILTER 这类高级函数的 Python 翻译,再用一个“金叉—回踩—再启动”类型的教学样例完整走一遍复现流程,最后把指标信号接到自动下单的桥接层。内容偏进阶,默认你已经有 QMT 的数据获取和基础 DataFrame 操作能力;如果还没看过上篇,建议先把 MA、MACD、KDJ 的向量化写法过一遍再回来。

1. 复现前的难度分级:你到底在复现“什么类型”的指标

1.1 四个难度等级的判断方法

先把通达信指标按复现难度分个级。我发现很多人在 QMT 里改一晚上改不出来,其实不是 Python 水平问题,而是根本没意识到指标内部用了哪类机制。通达信公式语言的执行逻辑是逐K线扫描,但大多数函数是向量化的,所以第一眼看起来都很简单,可一旦碰到“条件成立后还要持续若干根K线”的写法,用纯向量思维去解就特别容易绕晕。

我把常见指标分成四类,分别说明其特征和复现策略:

难度等级典型特征典型函数复现策略
L1 纯计算类只依赖当前K线和固定周期窗口MA、EMA、REF、HHV、LLV、STDpandas rolling 直接平移
L2 信号交叉类判断两序列在本K线发生关系变化CROSS、ISDOWN、ISUP前后两根K线值比较,vectorized 写法
L3 状态持久类依赖历史上最近一次条件成立的距今K线数BARSLAST、BARSLASTCOUNT、FILTER、VALUEWHEN、BACKSET辅助函数封装,常见用循环或聚合思路
L4 周期混合与未来数据类引用其他周期数据,或使用事后才能确定的拐点ZIG、PEAK、TROUGH、DRAWLINE 和各类 #WEEK/#MONTH 调用重采样对齐,并必须改造未来函数

判断一个指标属于哪一级,最直接的办法是在通达信公式编辑器里按Ctrl+F把用到的函数列一遍:只要看到 BARSLAST、BARSLASTCOUNT、BACKSET、FILTER、VALUEWHEN 这类出现,就说明指标内部存在跨K线的状态记忆,单纯平移公式一定出问题。只要看到#WEEK、#MONTH这类跨周期引用,就必须做数据重采样。而看到 ZIG、PEAK、TROUGH 或者画在未来位置上的 DRAWLINE,就要立刻警惕:这很可能是一个含未来数据的指标。

1.2 为什么“未来函数类指标”不能直接复现

先说一条铁律:凡是用了未来函数的指标,你在任何平台上都不可能精确复现,这不是编程能力问题,是时间方向本身就反了。

举个例子,通达信里很常见的 ZIG 指标,用来画“之字形”拐点,它判断当前K线是不是阶段高低点,标准是“与后面几根K线的极值做比较”。也就是说,当下这根K线的信号,要等后面几根K线走完才能确认。在历史图上跑,因为数据是完整的,你看到的结果似乎很合理;但在实盘中,每一根新K线都可能推翻之前的拐点判断,信号会来回修正。很多人在 QMT 里复现这种指标后,发现回测收益极好,实盘一跑就崩,根因就在这里。这也是“量化泄露未来信息”这个词在交易圈里被反复讨论的核心场景。

我的处理原则是:遇到未来函数指标,先分清楚它要表达的交易思想是什么,再把这个思想改造成“实时可用”的逻辑。比如 ZIG 的本质是“识别趋势中的转折”,那就可以用均线斜率、价格突破前高前低等实时可确认的条件去替代,而不是硬套 ZIG 数学公式。改出来的指标和原版图形肯定不完全一样,但交易逻辑是连续、可执行的,这才是量化环境里真正需要的东西。记住,复现指标的目的是拿到稳定可用的信号,不是为了在图上画出一模一样的线。

1.3 从副图指标入手,控制复现复杂度

实际拆解时,我建议从副图指标而不是主图指标练手。副图指标通常是 MACD、KDJ、RSI、量能类,计算逻辑相对规整,信号是“单值输出”,比较好验证。主图指标常常叠加了画线、填充、未来拐点判断,视觉效果好,但拆起来非常伤神。

还要注意一点:通达信里很多网上分享的指标源码,看起来是副图,但内部悄悄嵌套了未来函数或跨周期调用,复制到 QMT 里跑出的结果会非常诡异。我每接手一个指标,第一步就是先做上面那个“函数审阅”,确定等级后再说翻译的事。这一步省下来的时间远比想象中多,千万别跳。

2. 通达信高级状态函数在 Python 里的标准翻译

2.1 CROSS 函数:最常见的“差一根K线”翻车点

虽然 CROSS 只是 L2 等级,但翻车率很高,先把它彻底讲干净。通达信里的CROSS(A, B)定义为:当 A 从小于等于 B 的状态,在当根K线变成大于 B,也就是“上穿”。很多人会把它直接翻译成(a > b),这在向量化回测里会漏掉一个前提“前一K线不满足条件”,导致信号多出一大堆。

标准翻译如下:

import pandas as pd def cross_up(a: pd.Series, b: pd.Series) -> pd.Series: """通达信 CROSS 的等价实现:上一根 a<=b 且当前 a>b""" return (a.shift(1) <= b.shift(1)) & (a > b)

注意这里我用的是<=,不是<,因为通达信 CROSS 对等于的情况也视为“之前未上穿”。如果你用成<,当 A 和 B 连续两根都在同一水平时,信号方向判断就会出错。还有一点,shift(1)之后第一个位置是 NaN,和任何值比较都会返回 False,所以不需要额外填充,除非你后续要做布尔值的算术运算,那才需要.fillna(False)。

关于“差一根K线”的细节再展开一下:实盘中,当根K线收盘才能计算的信号,真正能下单的窗口是下一根K线。通达信里用BARSSINCE或REF可以绕,但 QMT 回测环境里就要自己做好“信号延迟一拍”的处理。保守做法是把信号列整体shift(1)之后再作为下单位条件,这个原则在第三节会专门演示。

2.2 BARSLAST:距上次条件成立经过了多少根K线

BARSLAST(COND)是我见过最多人挠头的通达信函数,它的含义是“从当前K线往回数,上一次条件 COND 成立时,距离当前K线经过了多少根K线”。注意,如果当前K线条件本身为真,BARSLAST 返回 0;如果历史中从未出现过条件,返回的是缺失值或一个很大的数。

这种逻辑在 Python 里一个直观写法就是循环:

import numpy as np def barslast(cond: pd.Series) -> pd.Series: cond = cond.reset_index(drop=True).astype(bool) out = np.zeros(len(cond), dtype=int) last_true = -1 for i in range(len(cond)): if cond.iloc[i]: last_true = i out[i] = i - last_true if last_true >= 0 else 0 return pd.Series(out, cond.index)

不过当数据量上万条时,逐行 Python 循环会拖慢速度。更快的向量化写法是利用“最近一次条件为真的位置”的积累操作:

def barslast_vec(cond: pd.Series) -> pd.Series: cond = cond.reset_index(drop=True).astype(bool) idx = np.arange(len(cond)) last_pos = np.where(cond, idx, -1) last_pos = np.maximum.accumulate(last_pos) out = np.where(last_pos >= 0, idx - last_pos, 0) return pd.Series(out, cond.index)

核心思路是:把条件为真的位置记录下来,然后用maximum.accumulate把“最近一次为真”的位置广播到每一个后续K线,最后用当前K线位置减去它。我在实际项目里常用这个向量化版,性能比循环版提升非常明显,而且结果完全一致。唯一要提醒的是:如果你的 pandas 索引不是从 0 开始的连续整数,务必先reset_index(drop=True),否则位置减法会错位。

还有一个容易踩的坑:BARSLAST 的结果是“经过的K线数”,不是“距今多少分钟”。在 5 分钟周期里,BARSLAST=3 表示距离上次条件成立经过了 3 根5分钟K线,也就是 15 分钟前。如果你要把它换算成实际时间再去和行情时间比对,得单独乘上周期长度,别直接把数字当时间用。

2.3 BARSLASTCOUNT:连续条件成立的计数

BARSLASTCOUNT(COND)返回的是当前K线为止,条件连续成立了多少根。比如“连续上涨天数”就可以用它表达。这个函数很多人误以为等价于rolling(N).sum(),但 N 是未知的,你必须根据条件逐根累计。

向量化方案如下:

def barslastcount(cond: pd.Series) -> pd.Series: cond = cond.reset_index(drop=True).astype(bool) group = (~cond).cumsum() return group.map(...) if False else None

其实最清晰的写法是分组计数。因为条件为假的那一根会把分组号加一,所以同一个连续为真的区间都落在同一组里,对组内做cumsum就是连续成立数量:

def barslastcount(cond: pd.Series) -> pd.Series: cond = cond.reset_index(drop=True).astype(bool) group = (~cond).cumsum() count = cond.groupby(group).cumsum() return pd.Series(count.values, cond.index)

你可以验证一下:条件序列为True, True, False, True, True, True时,输出应该是1, 2, 0, 1, 2, 3。这个函数在写“连板高度”“连续放量N天”这类指标时非常常用。

2.4 VALUEWHEN 与 FILTER:取值和信号屏蔽

VALUEWHEN(COND, X)的含义是:当 COND 条件成立时,取 X 的数值;如果当前K线条件不成立,则取最近一次条件成立时的 X 值。实现起来比 BARSLAST 简单,用 pandas 的 where 加 ffill 即可:

def valuewhen(cond: pd.Series, x: pd.Series) -> pd.Series: return x.where(cond).ffill()

FILTER(X, N)则负责信号屏蔽:当 X 出现一个有效信号后,从下一根K线开始连续 N 根K线内,即使 X 再次为真也不再输出;直到 N 根之后才允许再次输出,避免同类信号在短时间内频繁出现。典型应用场景就是买点信号过滤,防止金叉之后紧接着再出一次金叉。

def filter_signal(cond: pd.Series, n: int) -> pd.Series: cond = cond.reset_index(drop=True).astype(bool) out = np.zeros(len(cond), dtype=bool) suppress_until = -1 for i in range(len(cond)): if cond.iloc[i] and i > suppress_until: out[i] = True suppress_until = i + n return pd.Series(out, cond.index)

这个函数用循环更直观,因为信号屏蔽天然存在先后依赖。如果数据量很大,可以通过“取条件为真的位置,然后按间隔筛选”的方式优化,原理一样,但代码可读性会差一些。我一般只在超过几十万行的数据集上才考虑优化这段,普通行情数据用循环没有压力。

2.5 一组可直接复制的高级函数工具包

把上面几个函数收拢起来,就是一套日常复现通达信指标的基础工具包:

# tdx_utils.py import pandas as pd import numpy as np def cross_up(a, b): return (a.shift(1) <= b.shift(1)) & (a > b) def barslast(cond): cond = cond.reset_index(drop=True).astype(bool) idx = np.arange(len(cond)) last_pos = np.maximum.accumulate(np.where(cond, idx, -1)) return pd.Series(np.where(last_pos >= 0, idx - last_pos, 0), cond.index) def barslastcount(cond): cond = cond.reset_index(drop=True).astype(bool) group = (~cond).cumsum() return pd.Series(cond.groupby(group).cumsum().values, cond.index) def valuewhen(cond, x): return x.where(cond).ffill() def filter_signal(cond, n): cond = cond.reset_index(drop=True).astype(bool) out = np.zeros(len(cond), dtype=bool) suppress_until = -1 for i in range(len(cond)): if cond.iloc[i] and i > suppress_until: out[i] = True suppress_until = i + n return pd.Series(out, cond.index)

这个工具包我维护了很久,遇到新的通达信指标,第一件事就是对照函数清单,把能用现成辅助函数替换的先替换掉,剩下的才是需要单独写逻辑的状态机部分。

3. 完整复现流程:拆解一个“金叉—回踩—再启动”类教学指标

3.1 先在通达信里看清信号结构

我拿一个网上常见的“三步点金”类副图信号做教学样例。先说明,下面这段是为了讲透方法写的示意版本,结构模仿这类信号常见的“金叉—回踩—再启动”三步判定,不代表任何特定原版源码,你直接用它测试和拆解逻辑即可。

通达信公式示意如下:

MA5:=MA(CLOSE,5); MA10:=MA(CLOSE,10); MA20:=MA(CLOSE,20); TREND:=(MA5>MA10) AND (CLOSE>MA20); STEP1:=CROSS(MA5,MA10); STEP2:=(LOW<=MA10) AND (CLOSE>MA10) AND REF(TREND,1); STEP3:=(CLOSE>REF(HHV(CLOSE,5),1)) AND (VOL>REF(VOL,1)*1.2); SIGNAL:STEP1 OR (STEP2 AND STEP3); FINAL:FILTER(SIGNAL,5);

这个结构很典型:第一步是均线金叉,给出趋势启动;第二步是回踩确认,要求盘中低点跌到 10 日线附近但收盘拉回,且前一个K线趋势方向已经成立;第三步是再启动,要求收盘价突破前 5 根K线的最高收盘价,同时成交量比上一根放大 1.2 倍。最后再用 FILTER 屏蔽 5 根K线内的重复信号。

3.2 Python 复现与字段说明

在 QMT 拿到 DataFrame 后,按这个顺序实现:

import pandas as pd import numpy as np # 伪代码:qmt 返回的 df 必须包含 open/high/low/close/volume 五个字段 # df = xtdata.get_market_data_ex([], stock_code, period='1d', count=-1)[stock_code] df['ma5'] = df['close'].rolling(5).mean() df['ma10'] = df['close'].rolling(10).mean() df['ma20'] = df['close'].rolling(20).mean() trend = (df['ma5'] > df['ma10']) & (df['close'] > df['ma20']) step1 = cross_up(df['ma5'], df['ma10']) step2 = (df['low'] <= df['ma10']) & (df['close'] > df['ma10']) & trend.shift(1).fillna(False) step3 = (df['close'] > df['close'].rolling(5).max().shift(1)) & ( df['volume'] > df['volume'].shift(1) * 1.2 ) signal_raw = step1 | (step2 & step3) final_signal = filter_signal(signal_raw, 5) df['signal'] = final_signal

这段代码里需要特别解释两个细节:第一,trend.shift(1)对应通达信里的REF(TREND,1),意思是回踩确认必须发生在趋势已经成立之后,如果当前K线才刚金叉,就不算有效回踩。第二,df['close'].rolling(5).max().shift(1)对应REF(HHV(CLOSE,5),1),取的是前一根K线往前数 5 根的最高收盘价,千万别漏掉 shift,漏了就是未来数据。

3.3 信号验证:怎么确认复现是对的

复现之后不能只看有没有信号,更要验证信号出现的“位置”和通达信里是否一致。我的验证方法很笨但很有效:取一段有代表性的历史区间,把通达信副图里每个信号出现的日期列出来,然后在 QMT 的 DataFrame 里筛出final_signal为 True 的日期,逐一比对。如果位置有偏移,先检查是不是 REF 和 shift 错位;如果频率不对,先检查 FILTER 的屏蔽窗口是否实现正确。

我还习惯把信号叠加到 K 线图上肉眼观察一遍,重点看这几类:金叉后的第一个买点是否出现;回踩信号是否都出现在上涨趋势的回调段,而不是下跌趋势中的随机位置;FILTER 是否真的把 5 根K线内的重复信号屏蔽掉了。用肉眼先筛一遍,再让代码去计算胜率、盈亏比,这样能在早期发现逻辑错误,省下后面回测定位问题的时间。

3.4 实盘信号的延迟处理

这节特别重要。上面算出final_signal是当根K线收盘之后才确定的值,因为close、volume都是收盘或全量数据。如果你在 QMT 的自动交易里,用df['signal'].iloc[-1]在盘中实时判断,这根K线还没收盘,close和volume都是动态变化的,信号可能反复跳动。所以实盘策略里,我通常会把信号列再整体右移一根:

df['trade_signal'] = df['signal'].shift(1).fillna(False)

trade_signal为 True 意味着“上一根K线收盘后确认了买点,当前这根K线执行下单”。这样虽然会晚一根K线,但信号是稳定、可执行的。尤其是本例中 STEP2 用了 LOW 和 CLOSE,盘中“收盘拉回”这个动作只有收盘才能确认,延迟处理是必须的。

4. 跨周期指标复现:让小时级信号驱动分钟级K线

4.1 为什么跨周期引用是难点

通达信里写跨周期指标很简单,经常是一句KDJ:=KDJ(9,3,3); K:="#MIN60.K";就完了。但 QMT 的 DataFrame 是单周期的,你必须自己同时下载两套周期数据,然后做时间对齐。对齐本身不难,难的是处理“未完成K线”。

举个例子,你想用 60 分钟级别的 KDJ 多头趋势,来过滤 5 分钟级别上出现的买入信号。如果当前这根 5 分钟K线运行到 14:35,对应的那根 60 分钟K线(14:00到15:00)还没结束,它的 close、low、high 都在变化,KDJ 也一直在变。如果你直接把这个“正在变化的60分钟KDJ”用于过滤,5 分钟信号会随着 60 分钟指标的变化来回开关,实盘根本没法执行。

解决原则只有一条:只用“已经收盘确认的”大周期K线数据。具体操作是把大周期指标整体 shift 一根,或是在代码里判断当前时间是否处于新的大周期第一根K线,只用上一根大周期K线的指标值。

4.2 在 QMT 里获取并重采样多周期数据

先获取分钟数据,然后通过 pandas 重采样生成 60 分钟数据:

import pandas as pd # 伪代码:得到 1 分钟的 DataFrame # df_min = xtdata.get_market_data_ex([], stock_code, period='1m', count=-1)[stock_code] df_min.index = pd.to_datetime(df_min.index) df_60 = df_min.resample('60min', label='right', closed='right').agg({ 'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last', 'volume': 'sum' }).dropna()

这里有两个参数值得解释:closed='right'表示一个 60 分钟区间按右边界闭合方式切分,label='right'表示把区间结束时间作为这根 K 线的标签。A 股分钟数据通常以 09:35、09:36… 这样的时间戳表示当根K线结束时间,所以用label='right'能保持时间语义一致。重采样后,df_60的每根K线都是完整一小时的聚合结果。

4.3 大周期指标计算与向下对齐

在 60 分钟 DataFrame 上算好 KDJ 或 MACD 后,再对齐回分钟级别:

k60, d60, j60 = kdj(df_60) # 复用你自己封装好的 kdj 函数 df_min['k60'] = k60.reindex(df_min.index, method='ffill') df_min['d60'] = d60.reindex(df_min.index, method='ffill') df_min['j60'] = j60.reindex(df_min.index, method='ffill') # 关键:盘中过滤时只能使用上一根已收盘的60分钟值 df_min['k60_ok'] = df_min['k60'].shift(1) > df_min['d60'].shift(1)

reindex(..., method='ffill')会把每个分钟K线对应到当前所属的 60 分钟区间的最新值,但那个值可能是未完成K线的动态值。所以我又追加了一步shift(1),确保用的是“上一根已经走完的60分钟K线”的指标。如果你策略允许在 60 分钟K线收盘后立即决策,可以把shift(1)改成按时间判断当前K线是不是 60 分钟周期的第一根,但后者实现起来要小心节假日和午间休市,不如shift(1)稳定。

4.4 高频周期的“信号确认”技巧

跨周期复现最容易出现的问题是:日线或小时级别的信号明明正常,但到了分钟级就变得极其稀疏或极其频繁。原因往往是你直接把大周期信号广播到了每一个分钟K线上,导致同一个大周期买点在分钟级被重复计算,或者 FILTER 在大周期算好后没考虑分钟级重复。

我的习惯是:大周期只输出“状态”,不输出“交易信号”。比如 60 分钟 KDJ 只输出“多头状态”这一个布尔值,分钟级则负责输出具体的买点触发条件。状态可以持续多根分钟K线,买点只出现一次。这样逻辑清爽,后续做自动化下单也更容易控制频率。

5. 从指标信号到自动化下单的最后一公里

5.1 先把信号整理成可消费的表格

指标复现完成后,下一步就是让策略进程能消费它。我建议把信号统一输出成一张干净的订单候选表,字段包含:

# order_candidates DataFrame 应包含的字段 # time : 信号确认的时间 # stock : 股票代码 # signal : 1=买入,-1=卖出,0=无操作 # strength : 信号强度,可以用 KDJ 的 J 值或量比填充 # reason : 触发的信号名称,方便复盘

字段尽量规范,尤其是reason。很多人在 QMT 里跑了一段时间后想复盘,发现信号是有的,但不知道当时为什么触发,因为所有信号都记成一个 buy。加一个 reason 字段成本很低,复盘价值极高。

5.2 策略主循环与调度的基本架构

自动交易的主循环不需要复杂。以定时任务为例,每分钟拉一次最新行情,重算指标,再查一下信号表有没有新增信号:

def strategy_loop(): df = get_latest_kline(stock, period='1m', count=200) df = calc_all_indicators(df) new_signal = detect_signal(df) if new_signal != 0: place_order(stock, new_signal, volume=100)

这里要特别注意幂等性:同一根K线确认出来的信号,不能因为定时任务重复触发而重复下单。常见做法是记录上一次已处理的K线时间戳,本次只处理比它新的K线数据:

processed_bar_time = None def strategy_loop(): global processed_bar_time df = get_latest_kline(...) latest_time = df.index[-1] if latest_time == processed_bar_time: return processed_bar_time = latest_time # ... 剩下的信号判断与下单逻辑

5.3 用 HTTP 桥接层把策略和交易客户端连接起来

在实际部署中,我发现很多人遇到一个尴尬情况:策略代码跑在轻量级 Python 环境里,数据计算和数据获取都没问题,但下单函数只能通过完整版交易客户端暴露。于是这两年社区里开始流行一种做法:用 HTTP 接口在策略进程和交易客户端之间搭一个桥接层,策略只管算信号,把下单请求通过本地 HTTP 调用发给交易端,交易端负责执行、回报和风控。

这个思路我来回用了很久,确实能解决“策略环境”和“交易环境”分离的问题。一个最小示意如下:

from http.server import HTTPServer, BaseHTTPRequestHandler import json class TradeBridge(BaseHTTPRequestHandler): def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) payload = json.loads(self.rfile.read(content_length)) action = payload.get('action') stock = payload.get('stock') volume = payload.get('volume') # 这里只做参数校验,实际下单调用封装好的 QMT 交易接口 if action not in ('buy', 'sell'): self.send_response(400) self.end_headers() return # 调用交易客户端封装的 order 函数,示意 # result = trading_client.order(stock, action, volume) result = {'status': 'filled', 'stock': stock, 'volume': volume} self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps(result).encode('utf-8')) if __name__ == '__main__': server = HTTPServer(('127.0.0.1', 8808), TradeBridge) server.serve_forever()

这段代码只是一个结构性示范,具体的下单函数要根据你所用 QMT 交易接口来封装。但有几个原则是通用的:第一,桥接服务只绑定127.0.0.1,不要暴露到公网;第二,所有下单请求都要做参数白名单校验,至少校验股票代码、买卖方向、手数范围;第三,下单前必须有成交回报确认,不能只发出请求就认为成交了。

5.4 风控检查清单

自动化上线前,我建议把下面这张风控清单过一遍。很多问题不是指标逻辑问题,而是工程层面的“手滑”问题:

  • 单标的单日最大买入次数和累计买入数量
  • 全天最大亏损回撤,触发后暂停交易并在群里通知
  • 信号重复触发的幂等控制,确保同一根K线只处理一次
  • 下单失败的重试次数,避免网络抖动时反复提交
  • 连接断开后的自动恢复策略,优先恢复数据链路再恢复交易
  • 沙盒环境的参数与实盘环境隔离,不要混用配置

QMT 沙盒环境适合先跑全流程熟悉接口,但我测试下来的体会是:沙盒和实盘在委托字段、成交回报细节上有差异,所以最稳的验证方法还是先做“只记录信号,不真实下单”的观察期,跑两到三周,确认信号和预期的买卖点一致后再放开自动下单。

6. 性能优化与常见错误排查

6.1 为什么纯 Python 循环在 QMT 里跑得特别慢

很多人写完指标复现后,第一版总是“跑得出结果,但是慢到没法用”。最常见的原因是:在 DataFrame 上做逐行循环,而且循环体内还在大量使用 pandas 的索引切片。pandas 的索引操作有开销,几万行数据循环下来,时间会放大非常多。

我的优化顺序是:先把指标翻译成向量化计算,减少循环;如果遇到必须循环的状态类逻辑(比如 FILTER 这种天然有先后依赖的),先把 Series 转成 numpy 数组循环,算完再转回 pandas。下面这段代码是没有必要做 DataFrame 循环的示范,注意对比:

# 慢:逐行访问 DataFrame 的列 for i in range(1, len(df)): df.loc[i, 'ma5'] = df['close'].iloc[i-4:i+1].mean() # 快:直接用 rolling df['ma5'] = df['close'].rolling(5).mean()

数据量到几十万行时,两者的性能差距是数量级的。QMT 的分钟级历史数据动不动就是几十万根K线,一开始就养成分层优化的习惯能省很多等待时间。

6.2 复权方式不一致:回测对不上历史图的常见原因

通达信默认显示前复权价格,而 QMT 在下载历史数据时,复权方式需要单独设置。如果你拿通达信复盘里看到的信号位置和 QMT 回测结果对不上,第一嫌疑就是复权方式不一致。

前复权的特点是:价格会随历史分红送股调整,越早的K线价格越低;后复权则相反;不复权则是原始成交价。对指标计算而言,均线、动量、突破这类型的价格触发性指标,在复权方式不同时,历史信号位置会明显不同,甚至可能差出好几根K线。所以我的习惯是:先确定 QMT 数据下载时的复权字段设为前复权,再和通达信对比同一时间段的价格,确认两者一致后才开始复现指标。

6.3 常见错误速查表

症状可能原因排查方向
信号整体比通达信晚一根K线REF 和 shift 方向理解反了检查所有 REF 是否对应 .shift(1),确认信号确认时间和下单位置
信号明显多出很多CROSS 实现漏了上一K线比较用(a.shift(1) <= b.shift(1)) & (a > b)替换简单的(a > b)
信号出现时间不对,像是提前知道了未来用了 rolling(5).max() 没有再次 shift,或在当根K线使用了未收盘的 close价格突破类条件统一用“上一根K线收盘价已确认”的写法
FILTER 没起作用先调 filter_signal,再又对结果做延迟处理,顺序搞混先 FILTER,再 shift 生成交易信号,两步分开
跨周期信号频繁闪烁直接 reindex 广播了未完成大周期K线值对齐后整体 shift(1),只用已收盘大周期值
回测结果和通达信图形完全对不上复权方式不一致确认 QMT 下载数据的前复权设置

6.4 实盘运行后的增量更新策略

历史数据一次性计算完成后,实盘里更常见的场景是“每天新增一根或几根K线”。这时候不需要每次全量重算整个 DataFrame,只需要增量计算最近若干根K线。我习惯的做法是保留一份历史计算结果,每天拉取最后 50 根K线重新计算指标,再拼接回去。50 根K线对于均线周期不超过 20 的指标来说足够覆盖需要前瞻的窗口,计算量很小,也不会引入未来数据。

增量更新最容易出问题的地方是 rolling 窗口的边界。如果只取最后 50 根K线,就必须保证这 50 根数据是连续的、没有缺口的,否则 rolling(5).mean() 会把缺口当成正常数据,计算结果带着暗伤。每次更新时检查一下日期连续性,成本很低。

6.5 复盘记录的落地

最后分享一个我坚持了很久的小习惯:每次信号触发后,把触发时的分钟级行情快照、指标值、最终是否成交,一并存进本地数据库,字段至少包括时间、代码、信号类型、触发条件详情、当时的指标数值。这样做的好处短期内看不出来,但积累三到六个月后,你可以直接拿这些记录做信号迭代分析,找出哪些条件下信号成功率明显偏高,哪些条件下老是假突破。很多量化优化思路,其实是从这些看似无用的历史记录里冒出来的。

我在实际复现了几十个通达信指标之后,最深的一个体感是:先判断指标属于哪个难度等级,再决定用工具函数还是单独写状态逻辑,永远比拿到公式就直接翻译靠谱。通达信公式写得再花哨,底层无非是“数值计算、状态记忆、周期对齐”这三件事。把这三大类问题拆清楚,复现速度会快得很明显。剩下那些含未来函数的指标,也别一棍子打死,能改成实时逻辑就改,改不了就果断放弃,至少你不会在实盘里被反复变化的信号坑到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询